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

+1
View File
@@ -3,6 +3,7 @@
__pycache__/ __pycache__/
.pytest_cache/ .pytest_cache/
.ruff_cache/ .ruff_cache/
.codegraph/
.playwright-cli/ .playwright-cli/
*.sqlite3 *.sqlite3
*.sqlite3-* *.sqlite3-*
+93
View File
@@ -0,0 +1,93 @@
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;
const state = document.createElement("div");
state.className = "empty-state";
const title = document.createElement("div");
title.className = "empty-state-title";
title.textContent = message;
state.append(title);
cell.append(state);
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();
});
+1
View File
@@ -0,0 +1 @@
.admin-console{padding:32px 0 18px;background:var(--rp-canvas)} .admin-page-title{display:flex;align-items:end;justify-content:space-between;gap:20px} .admin-page-title h1{font-size:30px} .admin-total-strip,.admin-metric-strip{display:flex;flex-wrap:wrap;gap:8px} .admin-total-strip span,.admin-metric-strip span{display:inline-flex;align-items:center;gap:7px;min-height:34px;padding:6px 12px;border:1px solid var(--rp-line);border-radius:999px;background:var(--rp-surface);color:var(--rp-muted);font-size:13px;white-space:nowrap} .admin-total-strip strong,.admin-metric-strip strong{color:var(--rp-ink);font-size:14px;font-weight:600} .admin-block{padding:18px 0} .admin-block + .admin-block{border-top:1px solid var(--rp-soft-line)} .admin-section-heading{display:flex;align-items:end;justify-content:space-between;gap:20px;margin-bottom:10px} .admin-notice{margin:10px 0 0;border-radius:14px} .admin-cleanup-bar{display:grid;grid-template-columns:minmax(150px,0.8fr) minmax(0,2.3fr) auto;align-items:center;gap:16px;padding:14px 0;border-bottom:1px solid var(--rp-soft-line)} .admin-cleanup-title{display:grid;gap:2px;min-width:0} .admin-cleanup-title h2{font-size:22px} .admin-cleanup-title span{color:var(--rp-muted);font-size:13px} .admin-cleanup-times{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin:0} .admin-cleanup-times div{min-width:0} .admin-cleanup-times dt{margin:0;color:var(--rp-muted);font-size:12px;font-weight:600} .admin-cleanup-times dd{margin:0;overflow:hidden;color:var(--rp-ink);font-size:13px;text-overflow:ellipsis;white-space:nowrap} .admin-cleanup-bar form{justify-self:end} .admin-metric-strip{margin-top:12px} .metric-card{display:grid;gap:6px;padding:16px;border:1px solid var(--rp-line);border-radius:16px;background:var(--rp-surface)} .metric-card span{color:var(--rp-muted);font-size:13px;font-weight:500} .metric-card strong{color:var(--rp-ink);font-size:20px;font-weight:600} .admin-table-scroll{max-height:480px;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)} table{width:100%;min-width:900px;border-collapse:collapse;font-size:13px;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:10px 9px;text-align:left;vertical-align:middle} td{overflow-wrap:anywhere} .status-table{min-width:min(100%,420px)} .status-table th,.status-table td{white-space:normal;overflow-wrap:anywhere} .dashboard-page{background:var(--rp-canvas)} .dashboard-shell{padding:32px 0 18px} .dashboard-header{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:18px} .dashboard-header h1{margin-top:4px;font-size:32px} .dashboard-page .lead{margin-top:8px;font-size:15px} .dashboard-page .section-copy{margin-top:6px} .dashboard-page .alert{margin:0 0 18px;border-radius:12px} .dashboard-grid{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(280px,0.75fr);gap:16px;align-items:start} .dashboard-card{min-width:0;padding:0;border:1px solid var(--ui-border);border-radius:var(--ui-radius-lg);background:var(--ui-card);box-shadow:var(--ui-shadow-sm);overflow:clip} .dashboard-card-main{display:block} .dashboard-card-compact{margin-top:16px} .dashboard-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:18px 18px 0} .dashboard-card-header h2,.dashboard-section h2{margin-top:2px;font-size:22px} .dashboard-card [data-slot="card-content"]{display:grid;gap:12px;padding:14px 18px 18px} .dashboard-status-card [data-slot="card-header"]{padding:18px 18px 0} .dashboard-status-card [data-slot="card-content"]{padding-top:14px} .dashboard-copy-stack{display:grid;gap:8px} .dashboard-actions{display:flex;flex-wrap:wrap;gap:8px;padding:0 18px 18px} .dashboard-page .copy-field{gap:8px} .dashboard-page .copy-pill{min-height:48px} .dashboard-page .copy-button{min-width:96px;min-height:48px} .dashboard-page .copy-field code,.dashboarLine truncated
+1
View File
@@ -0,0 +1 @@
: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;--ui-background:var(--rp-bg);--ui-foreground:var(--rp-ink);--ui-card:var(--rp-surface);--ui-card-foreground:var(--rp-ink);--ui-muted:var(--rp-field);--ui-muted-foreground:var(--rp-muted);--ui-border:var(--rp-line);--ui-ring:var(--rp-focus-ring);--ui-destructive:var(--rp-danger);--ui-success:var(--rp-success);--ui-radius-sm:8px;--ui-radius-md:10px;--ui-radius-lg:14px;--ui-shadow-sm:0 1px 1px rgba(24,24,27,0.03),0 0 0 1px rgba(24,24,27,0.02)} [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;--ui-background:var(--rp-bg);--ui-foreground:var(--rp-ink);--ui-card:var(--rp-surface);--ui-card-foreground:var(--rp-ink);--ui-muted:var(--rp-field);--ui-muted-foreground:var(--rp-muted);--ui-border:var(--rp-line);--ui-ring:var(--rp-focus-ring);--ui-destructive:var(--rp-danger);--ui-success:var(--rp-success);--ui-shadow-sm:0 1px 1px rgba(0,0,0,0.18),0 0 0 1px rgba(255,255,255,0.02)} *{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} code{color:var(--rp-ink)} .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{align-items:center;appearance:none;background:transparent;border:0;border-radius:999px;color:var(--rp-muted);cursor:pointer;display:inline-flex;flex:0 0 auto;height:30px;justify-content:center;padding:0;width:30px} .theme-toggle:hover{background:var(--rp-surface-alt);color:var(--rp-ink)} .theme-toggle:focus-visible{outline:2px solid var(--rp-focus-ring);outline-offset:2px} .theme-toggle svg{height:17px;width:17px} .theme-toggle:active{transform:scale(0.96)} .theme-toggle::-moz-focus-inner{border:0;padding:0} .theme-icon{display:block} .theme-icon-sun{display:none} [data-bs-theme="dark"] .theme-icon-moon{display:none} [data-bs-theme="dark"] .theme-icon-sun{display:block} .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-compact{padding:56px 0} .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:varLine truncated
+87
View File
@@ -0,0 +1,87 @@
const legacyCopy = (value) => {
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "-9999px";
textarea.style.left = "-9999px";
document.body.append(textarea);
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
try {
return document.execCommand("copy");
} finally {
textarea.remove();
}
};
const copyText = async (value) => {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
return true;
}
return legacyCopy(value);
};
const showCopyStatus = (button, text, className, duration = 1200) => {
const label = button.querySelector("[data-copy-label]");
const oldLabel = label ? label.textContent : "";
button.classList.add(className);
if (label) {
label.setAttribute("aria-live", "polite");
label.textContent = text;
}
setTimeout(() => {
button.classList.remove(className);
if (label) label.textContent = oldLabel;
}, duration);
};
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;
const menu = button.closest("details.copy-menu");
try {
const copied = await copyText(button.dataset.copy || "");
if (!copied) throw new Error("copy failed");
showCopyStatus(button, "Copied", "is-copied");
if (menu) {
setTimeout(() => {
menu.removeAttribute("open");
}, 250);
}
} catch (_) {
showCopyStatus(button, "Copy failed", "is-copy-error", 1800);
}
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
document.querySelectorAll("details.copy-menu[open]").forEach((menu) => {
menu.removeAttribute("open");
});
});
+45
View File
@@ -0,0 +1,45 @@
(() => {
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);
});
})();
+99
View File
@@ -0,0 +1,99 @@
(() => {
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()) {
tasks.push(fetchIp("https://api6.ipify.org?format=json").then((ip) => {
replaceEverywhere(ipv6Placeholder, ip);
insertIpv6Examples(ip);
}));
}
await Promise.allSettled(tasks);
});
})();
+12 -11
View File
@@ -8,7 +8,7 @@ from fastapi.responses import PlainTextResponse
from .dns import DnsProviderError from .dns import DnsProviderError
from .security import credentials_from_basic_auth from .security import credentials_from_basic_auth
from .services.ddns import BadIpError, DdnsService from .services.ddns import BadIpError, DdnsService, UpdateIpParams
from .settings import DdnsSettings from .settings import DdnsSettings
from .store import DdnsStore from .store import DdnsStore
@@ -38,11 +38,8 @@ def authenticate_admin(
if not settings.admin_password: if not settings.admin_password:
raise HTTPException(status_code=500, detail="DDNS_ADMIN_PASSWORD is required") raise HTTPException(status_code=500, detail="DDNS_ADMIN_PASSWORD is required")
supplied = None supplied = _basic_auth_password(authorization)
if authorization and authorization.lower().startswith("basic "): if supplied is None or not secrets.compare_digest(supplied, settings.admin_password):
_, supplied = credentials_from_basic_auth(authorization)
if not supplied or not secrets.compare_digest(supplied, settings.admin_password):
raise HTTPException( raise HTTPException(
status_code=401, status_code=401,
detail="admin authentication required", detail="admin authentication required",
@@ -50,17 +47,21 @@ def authenticate_admin(
) )
def _basic_auth_password(authorization: str | None) -> str | None:
if authorization is None or not authorization.lower().startswith("basic "):
return None
_, password = credentials_from_basic_auth(authorization)
return password
def apply_update_response( def apply_update_response(
service: DdnsService, service: DdnsService,
request: Request, request: Request,
hostname: str, hostname: str,
myip: str | None, ip_params: UpdateIpParams,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> str | PlainTextResponse: ) -> str | PlainTextResponse:
try: try:
return service.apply_update(request, hostname, myip, ipaddr, myipv6, ip6addr).result.dyndns_response return service.apply_update(request, hostname, ip_params).result.dyndns_response
except BadIpError: except BadIpError:
return PlainTextResponse("badip", status_code=400) return PlainTextResponse("badip", status_code=400)
except DnsProviderError: except DnsProviderError:
+24 -10
View File
@@ -55,7 +55,7 @@ def delete_dns_records(hostname: str, settings: DdnsSettings) -> None:
def dns_backend_configured(settings: DdnsSettings) -> bool: def dns_backend_configured(settings: DdnsSettings) -> bool:
validate_dns_provider_settings(settings) validate_dns_provider_settings(settings)
provider = _normalized_provider(settings) provider = _normalized_provider(settings)
return provider == "cloudflare" or provider == "rfc2136" or bool(settings.rfc2136_server) return _dns_backend_enabled(provider, settings)
def validate_dns_provider_settings(settings: DdnsSettings) -> None: def validate_dns_provider_settings(settings: DdnsSettings) -> None:
@@ -65,19 +65,33 @@ def validate_dns_provider_settings(settings: DdnsSettings) -> None:
if not settings.require_dns_provider: if not settings.require_dns_provider:
return return
if provider == "cloudflare": if provider == "cloudflare":
if not settings.cloudflare_api_token or not settings.cloudflare_zone_id: _validate_cloudflare_settings(settings)
raise DnsConfigurationError(
"DDNS_CLOUDFLARE_API_TOKEN and DDNS_CLOUDFLARE_ZONE_ID are required when DDNS_DNS_PROVIDER=cloudflare"
)
return return
if provider == "rfc2136" or settings.rfc2136_server: if _dns_backend_enabled(provider, settings):
if not settings.rfc2136_server or not settings.rfc2136_zone: _validate_rfc2136_settings(settings)
raise DnsConfigurationError(
"DDNS_RFC2136_SERVER and DDNS_RFC2136_ZONE are required when RFC2136 DNS publishing is enabled"
)
return return
raise DnsConfigurationError("DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured") raise DnsConfigurationError("DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured")
def _dns_backend_enabled(provider: str, settings: DdnsSettings) -> bool:
return provider == "cloudflare" or provider == "rfc2136" or bool(settings.rfc2136_server)
def _validate_cloudflare_settings(settings: DdnsSettings) -> None:
if settings.cloudflare_api_token and settings.cloudflare_zone_id:
return
raise DnsConfigurationError(
"DDNS_CLOUDFLARE_API_TOKEN and DDNS_CLOUDFLARE_ZONE_ID are required when DDNS_DNS_PROVIDER=cloudflare"
)
def _validate_rfc2136_settings(settings: DdnsSettings) -> None:
if settings.rfc2136_server and settings.rfc2136_zone:
return
raise DnsConfigurationError(
"DDNS_RFC2136_SERVER and DDNS_RFC2136_ZONE are required when RFC2136 DNS publishing is enabled"
)
def _normalized_provider(settings: DdnsSettings) -> str: def _normalized_provider(settings: DdnsSettings) -> str:
return settings.dns_provider.strip().lower() return settings.dns_provider.strip().lower()
+10 -2
View File
@@ -78,15 +78,23 @@ def _cloudflare_request(
request = urllib.request.Request(url, data=body, method=method) request = urllib.request.Request(url, data=body, method=method)
request.add_header("Authorization", f"Bearer {settings.cloudflare_api_token}") request.add_header("Authorization", f"Bearer {settings.cloudflare_api_token}")
request.add_header("Content-Type", "application/json") request.add_header("Content-Type", "application/json")
parsed = _read_cloudflare_json(request)
_raise_for_cloudflare_error(parsed)
return parsed
def _read_cloudflare_json(request: urllib.request.Request) -> dict[str, Any]:
try: try:
with urllib.request.urlopen(request, timeout=10) as response: with urllib.request.urlopen(request, timeout=10) as response:
parsed = json.loads(response.read().decode("utf-8")) return cast(dict[str, Any], json.loads(response.read().decode("utf-8")))
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
raise DnsProviderError(f"Cloudflare API returned HTTP {exc.code}") from exc raise DnsProviderError(f"Cloudflare API returned HTTP {exc.code}") from exc
except urllib.error.URLError as exc: except urllib.error.URLError as exc:
raise DnsProviderError("Cloudflare API request failed") from exc raise DnsProviderError("Cloudflare API request failed") from exc
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
raise DnsProviderError("Cloudflare API returned invalid JSON") from exc raise DnsProviderError("Cloudflare API returned invalid JSON") from exc
def _raise_for_cloudflare_error(parsed: dict[str, Any]) -> None:
if not parsed.get("success", False): if not parsed.get("success", False):
raise DnsProviderError("Cloudflare API returned an unsuccessful response") raise DnsProviderError("Cloudflare API returned an unsuccessful response")
return parsed
+89 -42
View File
@@ -1,67 +1,114 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Protocol
from ..models import UpdateResult from ..models import UpdateResult
from ..settings import DdnsSettings from ..settings import DdnsSettings
from .base import DnsProviderError from .base import DnsProviderError
class _RcodeResponse(Protocol):
def rcode(self) -> int: ...
@dataclass(frozen=True, slots=True)
class _RecordUpdate:
ttl: int
record_type: str
value: str | None
enabled: bool
def publish_rfc2136(result: UpdateResult, settings: DdnsSettings) -> None: def publish_rfc2136(result: UpdateResult, settings: DdnsSettings) -> None:
if not settings.rfc2136_server: if not settings.rfc2136_server:
return return
import dns.exception update, relative = _rfc2136_update(result.hostname, settings)
import dns.query _apply_update_records(update, relative, result, settings)
import dns.rcode _send_rfc2136_update(update, settings.rfc2136_server, "update")
import dns.tsigkeyring
import dns.update
keyring = None
if settings.rfc2136_key_name and settings.rfc2136_key_secret:
keyring = dns.tsigkeyring.from_text({settings.rfc2136_key_name: settings.rfc2136_key_secret})
zone = settings.rfc2136_zone or ".".join(result.hostname.split(".")[1:])
update = dns.update.Update(zone, keyring=keyring)
relative = result.hostname.removesuffix("." + zone).rstrip(".") or "@"
if result.update_ipv4 and result.ipv4:
update.replace(relative, settings.ttl, "A", result.ipv4)
elif result.update_ipv4:
update.delete(relative, "A")
if result.update_ipv6 and result.ipv6:
update.replace(relative, settings.ttl, "AAAA", result.ipv6)
elif result.update_ipv6:
update.delete(relative, "AAAA")
try:
response = dns.query.tcp(update, settings.rfc2136_server, timeout=5)
except dns.exception.DNSException as exc:
raise DnsProviderError("RFC2136 DNS update failed") from exc
rcode = response.rcode()
if rcode != 0:
raise DnsProviderError(f"RFC2136 update failed: {dns.rcode.to_text(rcode)}")
def delete_rfc2136_records(hostname: str, settings: DdnsSettings) -> None: def delete_rfc2136_records(hostname: str, settings: DdnsSettings) -> None:
if not settings.rfc2136_server: if not settings.rfc2136_server:
return return
update, relative = _rfc2136_update(hostname, settings)
update.delete(relative, "A")
update.delete(relative, "AAAA")
_send_rfc2136_update(update, settings.rfc2136_server, "delete")
def _rfc2136_update(hostname: str, settings: DdnsSettings) -> tuple[Any, str]:
import dns.update
zone = _rfc2136_zone(hostname, settings)
update = dns.update.Update(zone, keyring=_rfc2136_keyring(settings))
return update, _rfc2136_relative_name(hostname, zone)
def _rfc2136_zone(hostname: str, settings: DdnsSettings) -> str:
return settings.rfc2136_zone or ".".join(hostname.split(".")[1:])
def _rfc2136_relative_name(hostname: str, zone: str) -> str:
hostname = hostname.rstrip(".")
zone = zone.rstrip(".")
if hostname == zone:
return "@"
return hostname.removesuffix("." + zone).rstrip(".") or "@"
def _rfc2136_keyring(settings: DdnsSettings) -> Any | None:
if not settings.rfc2136_key_name or not settings.rfc2136_key_secret:
return None
import dns.tsigkeyring
return dns.tsigkeyring.from_text({settings.rfc2136_key_name: settings.rfc2136_key_secret})
def _apply_update_records(update: Any, relative: str, result: UpdateResult, settings: DdnsSettings) -> None:
_apply_record_update(
update,
relative,
_RecordUpdate(ttl=settings.ttl, record_type="A", value=result.ipv4, enabled=result.update_ipv4),
)
_apply_record_update(
update,
relative,
_RecordUpdate(ttl=settings.ttl, record_type="AAAA", value=result.ipv6, enabled=result.update_ipv6),
)
def _apply_record_update(
update: Any,
relative: str,
record: _RecordUpdate,
) -> None:
if not record.enabled:
return
if record.value is None:
update.delete(relative, record.record_type)
return
update.replace(relative, record.ttl, record.record_type, record.value)
def _send_rfc2136_update(update: Any, server: str, operation: str) -> None:
import dns.exception import dns.exception
import dns.query import dns.query
import dns.rcode import dns.rcode
import dns.tsigkeyring
import dns.update
keyring = None
if settings.rfc2136_key_name and settings.rfc2136_key_secret:
keyring = dns.tsigkeyring.from_text({settings.rfc2136_key_name: settings.rfc2136_key_secret})
zone = settings.rfc2136_zone or ".".join(hostname.split(".")[1:])
update = dns.update.Update(zone, keyring=keyring)
relative = hostname.removesuffix("." + zone).rstrip(".") or "@"
update.delete(relative, "A")
update.delete(relative, "AAAA")
try: try:
response = dns.query.tcp(update, settings.rfc2136_server, timeout=5) response = dns.query.tcp(update, server, timeout=5)
except dns.exception.DNSException as exc: except dns.exception.DNSException as exc:
raise DnsProviderError("RFC2136 DNS delete failed") from exc raise DnsProviderError(f"RFC2136 DNS {operation} failed") from exc
_raise_for_rfc2136_response(response, operation)
def _raise_for_rfc2136_response(response: _RcodeResponse, operation: str) -> None:
import dns.rcode
rcode = response.rcode() rcode = response.rcode()
if rcode != 0: if rcode != 0:
raise DnsProviderError(f"RFC2136 delete failed: {dns.rcode.to_text(rcode)}") raise DnsProviderError(f"RFC2136 {operation} failed: {dns.rcode.to_text(dns.rcode.Rcode(rcode))}")
+45 -50
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Annotated
from urllib.parse import urlencode from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
@@ -18,72 +19,67 @@ from ..services.ddns import normalize_hostname
from ..web.components import _copy_row from ..web.components import _copy_row
from ..web.forms import first_form_value, request_form from ..web.forms import first_form_value, request_form
from ..web.html_utils import SafeHtml from ..web.html_utils import SafeHtml
from ..web.templates_admin import _admin_notice, _admin_secret_panel, _render_admin_page from ..web.templates_admin import _admin_notice, _admin_page_context, _admin_secret_panel, _render_admin_page
ADMIN_TABLE_LIMIT = 100 ADMIN_TABLE_LIMIT = 100
ADMIN_JSON_OFFSET_CAP = 10_000 ADMIN_JSON_OFFSET_CAP = 10_000
AdminAuthDep = Annotated[None, Depends(authenticate_admin)]
AdminOffsetQuery = Annotated[int, Query(ge=0)]
CleanupDoneQuery = Annotated[bool, Query()]
CleanupCountQuery = Annotated[int, Query(ge=0)]
def _admin_table_offsets(
accounts_offset: AdminOffsetQuery = 0,
cleanup_runs_offset: AdminOffsetQuery = 0,
update_events_offset: AdminOffsetQuery = 0,
) -> dict[str, int]:
return {
"accounts": accounts_offset,
"cleanup_runs": cleanup_runs_offset,
"update_events": update_events_offset,
}
def _admin_cleanup_notice(
cleanup_done: CleanupDoneQuery = False,
cleanup_unused: CleanupCountQuery = 0,
cleanup_events: CleanupCountQuery = 0,
) -> SafeHtml | None:
if not cleanup_done:
return None
return _admin_notice(
"success",
f"Cleanup finished. Removed {cleanup_unused} hostnames and {cleanup_events} old events.",
)
AdminTableOffsetsDep = Annotated[dict[str, int], Depends(_admin_table_offsets)]
AdminCleanupNoticeDep = Annotated[SafeHtml | None, Depends(_admin_cleanup_notice)]
def make_router(admin_path: str = "/admin") -> APIRouter: def make_router(admin_path: str = "/admin") -> APIRouter:
router = APIRouter() router = APIRouter()
def render_admin( def render_admin(
service: ServiceDep,
settings: SettingsDep, settings: SettingsDep,
store: StoreDep, store: StoreDep,
table_offsets: dict[str, int] | None = None, table_offsets: dict[str, int] | None = None,
notice: SafeHtml | None = None, notice: SafeHtml | None = None,
) -> str: ) -> str:
table_offsets = table_offsets or {} table_offsets = table_offsets or {}
return _render_admin_page( context = _admin_page_context(store, table_offsets, ADMIN_TABLE_LIMIT, notice)
service, return _render_admin_page(settings, context)
settings,
store.list_accounts(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("accounts", 0)),
store.list_cleanup_runs(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("cleanup_runs", 0)),
store.list_scheduled_tasks(),
store.list_update_events(ADMIN_TABLE_LIMIT, offset=table_offsets.get("update_events", 0)),
store.get_service_metrics(),
{
"accounts": store.count_accounts(),
"cleanup_runs": store.count_cleanup_runs(),
"update_events": store.count_update_events(),
},
table_offsets,
ADMIN_TABLE_LIMIT,
notice,
)
@router.get(admin_path, response_class=HTMLResponse, include_in_schema=False) @router.get(admin_path, response_class=HTMLResponse, include_in_schema=False)
def admin( def admin(
settings: SettingsDep, settings: SettingsDep,
store: StoreDep, store: StoreDep,
service: ServiceDep, _: AdminAuthDep,
_: None = Depends(authenticate_admin), table_offsets: AdminTableOffsetsDep,
accounts_offset: int = Query(0, ge=0), notice: AdminCleanupNoticeDep,
cleanup_runs_offset: int = Query(0, ge=0),
update_events_offset: int = Query(0, ge=0),
cleanup_done: bool = Query(False),
cleanup_unused: int = Query(0, ge=0),
cleanup_events: int = Query(0, ge=0),
) -> str: ) -> str:
notice = ( return render_admin(settings, store, table_offsets, notice)
_admin_notice(
"success", f"Cleanup finished. Removed {cleanup_unused} hostnames and {cleanup_events} old events."
)
if cleanup_done
else None
)
return render_admin(
service,
settings,
store,
{
"accounts": accounts_offset,
"cleanup_runs": cleanup_runs_offset,
"update_events": update_events_offset,
},
notice,
)
@router.post(f"{admin_path}/hostnames/delete", include_in_schema=False) @router.post(f"{admin_path}/hostnames/delete", include_in_schema=False)
async def admin_delete_account( async def admin_delete_account(
@@ -91,7 +87,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
settings: SettingsDep, settings: SettingsDep,
store: StoreDep, store: StoreDep,
service: ServiceDep, service: ServiceDep,
_: None = Depends(authenticate_admin), _: AdminAuthDep,
) -> Response: ) -> Response:
form = await request_form(request, settings) form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf")) require_admin_csrf(settings, first_form_value(form, "csrf"))
@@ -118,7 +114,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
settings: SettingsDep, settings: SettingsDep,
store: StoreDep, store: StoreDep,
service: ServiceDep, service: ServiceDep,
_: None = Depends(authenticate_admin), _: AdminAuthDep,
) -> Response: ) -> Response:
form = await request_form(request, settings) form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf")) require_admin_csrf(settings, first_form_value(form, "csrf"))
@@ -168,7 +164,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
) )
else: else:
raise HTTPException(status_code=400, detail="invalid rotation action") raise HTTPException(status_code=400, detail="invalid rotation action")
page = await run_in_threadpool(render_admin, service, settings, store, None, notice) page = await run_in_threadpool(render_admin, settings, store, None, notice)
return HTMLResponse(page) return HTMLResponse(page)
@router.post(f"{admin_path}/cleanup", include_in_schema=False) @router.post(f"{admin_path}/cleanup", include_in_schema=False)
@@ -176,8 +172,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
request: Request, request: Request,
settings: SettingsDep, settings: SettingsDep,
store: StoreDep, store: StoreDep,
service: ServiceDep, _: AdminAuthDep,
_: None = Depends(authenticate_admin),
) -> Response: ) -> Response:
form = await request_form(request, settings) form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf")) require_admin_csrf(settings, first_form_value(form, "csrf"))
+29 -15
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
@@ -33,6 +35,12 @@ router = APIRouter(prefix="/api/v1")
ADMIN_API_LIMIT_CAP = 500 ADMIN_API_LIMIT_CAP = 500
ADMIN_API_OFFSET_CAP = 10_000 ADMIN_API_OFFSET_CAP = 10_000
HOSTNAME_HISTORY_LIMIT_CAP = 100 HOSTNAME_HISTORY_LIMIT_CAP = 100
AdminAuthDep = Annotated[None, Depends(authenticate_admin)]
HostnameHistoryLimitQuery = Annotated[int, Query(ge=0, le=HOSTNAME_HISTORY_LIMIT_CAP)]
AdminLimitQuery = Annotated[int, Query(ge=1, le=ADMIN_API_LIMIT_CAP)]
AdminOffsetQuery = Annotated[int, Query(ge=0, le=ADMIN_API_OFFSET_CAP)]
AdminDashboardLimitQuery = Annotated[int, Query(ge=1, le=ADMIN_API_LIMIT_CAP)]
AdminBeforeQuery = Annotated[str | None, Query(max_length=64)]
@router.post( @router.post(
@@ -52,7 +60,10 @@ async def create_hostname(service: ServiceDep) -> HostnameCreateResponse:
"/hostnames/{dashboard_secret}", "/hostnames/{dashboard_secret}",
response_model=HostnameStatusResponse, response_model=HostnameStatusResponse,
summary="Read a hostname by private dashboard link", summary="Read a hostname by private dashboard link",
description="Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, not this API endpoint.", description=(
"Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, "
"not this API endpoint."
),
tags=["hostnames"], tags=["hostnames"],
responses={404: {"model": ErrorResponse}}, responses={404: {"model": ErrorResponse}},
) )
@@ -60,7 +71,7 @@ async def read_hostname(
dashboard_secret: ManagementSlugPath, dashboard_secret: ManagementSlugPath,
store: StoreDep, store: StoreDep,
service: ServiceDep, service: ServiceDep,
history_limit: int = Query(10, ge=0, le=HOSTNAME_HISTORY_LIMIT_CAP), history_limit: HostnameHistoryLimitQuery = 10,
) -> HostnameStatusResponse: ) -> HostnameStatusResponse:
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret) account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
if not account or account["disabled"]: if not account or account["disabled"]:
@@ -113,7 +124,10 @@ async def rotate_hostname_update_url(
"/hostnames/{dashboard_secret}", "/hostnames/{dashboard_secret}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a hostname by private dashboard link", summary="Delete a hostname by private dashboard link",
description="Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, not this API endpoint.", description=(
"Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, "
"not this API endpoint."
),
tags=["hostnames"], tags=["hostnames"],
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}, responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
) )
@@ -140,8 +154,8 @@ async def delete_hostname(dashboard_secret: ManagementSlugPath, store: StoreDep,
) )
async def read_admin( async def read_admin(
store: StoreDep, store: StoreDep,
_: None = Depends(authenticate_admin), _: AdminAuthDep,
limit: int = Query(25, ge=1, le=ADMIN_API_LIMIT_CAP), limit: AdminDashboardLimitQuery = 25,
) -> AdminDashboardResponse: ) -> AdminDashboardResponse:
metrics = await run_in_threadpool(store.get_service_metrics) metrics = await run_in_threadpool(store.get_service_metrics)
accounts = await run_in_threadpool(store.list_accounts, limit, 0) accounts = await run_in_threadpool(store.list_accounts, limit, 0)
@@ -175,9 +189,9 @@ async def read_admin(
) )
async def list_admin_hostnames( async def list_admin_hostnames(
store: StoreDep, store: StoreDep,
_: None = Depends(authenticate_admin), _: AdminAuthDep,
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP), limit: AdminLimitQuery = 100,
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP), offset: AdminOffsetQuery = 0,
) -> AdminHostnamesResponse: ) -> AdminHostnamesResponse:
accounts = await run_in_threadpool(store.list_accounts, limit, offset) accounts = await run_in_threadpool(store.list_accounts, limit, offset)
total = await run_in_threadpool(store.count_accounts) total = await run_in_threadpool(store.count_accounts)
@@ -200,10 +214,10 @@ async def list_admin_hostnames(
) )
async def list_admin_events( async def list_admin_events(
store: StoreDep, store: StoreDep,
_: None = Depends(authenticate_admin), _: AdminAuthDep,
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP), limit: AdminLimitQuery = 100,
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP), offset: AdminOffsetQuery = 0,
before: str | None = Query(None, max_length=64), before: AdminBeforeQuery = None,
) -> AdminEventsResponse: ) -> AdminEventsResponse:
items = ( items = (
await run_in_threadpool(store.list_update_events_before, limit, before) await run_in_threadpool(store.list_update_events_before, limit, before)
@@ -232,9 +246,9 @@ async def list_admin_events(
) )
async def list_admin_records( async def list_admin_records(
store: StoreDep, store: StoreDep,
_: None = Depends(authenticate_admin), _: AdminAuthDep,
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP), limit: AdminLimitQuery = 100,
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP), offset: AdminOffsetQuery = 0,
) -> AdminRecordsResponse: ) -> AdminRecordsResponse:
items = await run_in_threadpool(store.list_records_page, limit, offset) items = await run_in_threadpool(store.list_records_page, limit, offset)
total = await run_in_threadpool(store.count_records) total = await run_in_threadpool(store.count_records)
+12 -32
View File
@@ -5,31 +5,11 @@ from pathlib import Path
from fastapi import APIRouter, Response from fastapi import APIRouter, Response
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
from ..web.scripts import ADMIN_TABLES_JS, CLIPBOARD_JS, THEME_JS, VISITOR_IP_JS
from ..web.styles import ADMIN_CSS, BASE_CSS
ASSET_DIR = Path(__file__).parents[1] / "assets" ASSET_DIR = Path(__file__).parents[1] / "assets"
router = APIRouter(include_in_schema=False) router = APIRouter(include_in_schema=False)
ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"} ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
def _minify_css(css: str) -> str:
return (
" ".join(css.split())
.replace(" {", "{")
.replace("{ ", "{")
.replace("; ", ";")
.replace(": ", ":")
.replace(", ", ",")
.replace(" }", "}")
.replace(";}", "}")
)
MINIFIED_BASE_CSS = _minify_css(BASE_CSS)
MINIFIED_ADMIN_CSS = _minify_css(ADMIN_CSS)
@router.get("/favicon.ico") @router.get("/favicon.ico")
def favicon() -> FileResponse: def favicon() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS) return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS)
@@ -115,33 +95,33 @@ def site_webmanifest() -> Response:
@router.get("/theme.js") @router.get("/theme.js")
def theme_js() -> Response: def theme_js() -> FileResponse:
return Response(THEME_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS) return FileResponse(ASSET_DIR / "theme.js", media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/clipboard.js") @router.get("/clipboard.js")
def clipboard_js() -> Response: def clipboard_js() -> FileResponse:
return Response(CLIPBOARD_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS) return FileResponse(ASSET_DIR / "clipboard.js", media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/admin-tables.js") @router.get("/admin-tables.js")
def admin_tables_js() -> Response: def admin_tables_js() -> FileResponse:
return Response(ADMIN_TABLES_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS) return FileResponse(ASSET_DIR / "admin-tables.js", media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/visitor-ip.js") @router.get("/visitor-ip.js")
def visitor_ip_js() -> Response: def visitor_ip_js() -> FileResponse:
return Response(VISITOR_IP_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS) return FileResponse(ASSET_DIR / "visitor-ip.js", media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/app.css") @router.get("/app.css")
def app_css() -> Response: def app_css() -> FileResponse:
return Response(MINIFIED_BASE_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS) return FileResponse(ASSET_DIR / "app.css", media_type="text/css", headers=ASSET_CACHE_HEADERS)
@router.get("/admin.css") @router.get("/admin.css")
def admin_css() -> Response: def admin_css() -> FileResponse:
return Response(MINIFIED_ADMIN_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS) return FileResponse(ASSET_DIR / "admin.css", media_type="text/css", headers=ASSET_CACHE_HEADERS)
@router.get("/install.sh") @router.get("/install.sh")
+78 -45
View File
@@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
from typing import NoReturn from typing import Annotated, NoReturn
from fastapi import APIRouter, HTTPException, Request, Response from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
@@ -14,11 +14,13 @@ from ..event_statuses import (
DASHBOARD_UPDATE_URL_ROTATED, DASHBOARD_UPDATE_URL_ROTATED,
) )
from ..security import client_ip, dashboard_csrf_token, require_dashboard_csrf from ..security import client_ip, dashboard_csrf_token, require_dashboard_csrf
from ..services.ddns import BadIpError from ..services.ddns import BadIpError, UpdateIpParams
from ..web.forms import first_form_value, request_form from ..web.forms import first_form_value, request_form
from ..web.html_utils import trusted from ..web.html_utils import trusted
from ..web.layout import _page from ..web.layout import _page
from ..web.templates_management import ( from ..web.templates_management import (
ManagementPageContext,
PendingManagementPageContext,
_render_management_page, _render_management_page,
_render_management_unlock_page, _render_management_unlock_page,
_render_pending_management_page, _render_pending_management_page,
@@ -36,6 +38,19 @@ from .path_params import ManagementSlugPath
router = APIRouter() router = APIRouter()
router.include_router(management_passwords.router) router.include_router(management_passwords.router)
ActivationIpQuery = Annotated[str | None, Query()]
def _activation_ip_params_from_query(
myip: ActivationIpQuery = None,
ipaddr: ActivationIpQuery = None,
myipv6: ActivationIpQuery = None,
ip6addr: ActivationIpQuery = None,
) -> UpdateIpParams:
return UpdateIpParams(myip=myip, ipaddr=ipaddr, myipv6=myipv6, ip6addr=ip6addr)
ActivationIpParamsDep = Annotated[UpdateIpParams, Depends(_activation_ip_params_from_query)]
@router.get("/m/{management_slug}", response_class=HTMLResponse, include_in_schema=False) @router.get("/m/{management_slug}", response_class=HTMLResponse, include_in_schema=False)
@@ -43,27 +58,34 @@ def manage_dashboard(
management_slug: ManagementSlugPath, management_slug: ManagementSlugPath,
request: Request, request: Request,
settings: SettingsDep, settings: SettingsDep,
store: StoreDep,
service: ServiceDep, service: ServiceDep,
activated: bool = False, activated: bool = False,
) -> str: ) -> str:
account = get_active_management_account(store, management_slug) account = get_active_management_account(service.store, management_slug)
csrf_token = dashboard_csrf_token(management_slug, account) csrf_token = dashboard_csrf_token(management_slug, account)
if not management_account_is_accepted(account): if not management_account_is_accepted(account):
return _render_pending_management_page( return _render_pending_management_page(
service, settings, management_slug, account, csrf_token, request_ip=client_ip(request, settings) service,
PendingManagementPageContext(
settings=settings,
management_slug=management_slug,
account=account,
csrf_token=csrf_token,
request_ip=client_ip(request, settings),
),
) )
if not has_management_access(request, settings, management_slug, account): if not has_management_access(request, settings, management_slug, account):
return _render_management_unlock_page(management_slug, account, csrf_token) return _render_management_unlock_page(management_slug, account, csrf_token)
message = "Hostname registered. Add the update URL to your router." if activated else None message = "Hostname registered. Add the update URL to your router." if activated else None
return _render_management_page( return _render_management_page(
service, service,
settings, ManagementPageContext(
management_slug, management_slug=management_slug,
account, account=account,
csrf_token, csrf_token=csrf_token,
message=message, message=message,
request_ip=client_ip(request, settings), request_ip=client_ip(request, settings),
),
) )
@@ -72,30 +94,36 @@ async def activate_dashboard(
management_slug: ManagementSlugPath, management_slug: ManagementSlugPath,
request: Request, request: Request,
settings: SettingsDep, settings: SettingsDep,
store: StoreDep,
service: ServiceDep, service: ServiceDep,
myip: str | None = None, ip_params: ActivationIpParamsDep,
ipaddr: str | None = None,
myipv6: str | None = None,
ip6addr: str | None = None,
) -> RedirectResponse | HTMLResponse: ) -> RedirectResponse | HTMLResponse:
account = await get_active_management_account_async(store, management_slug) account = await get_active_management_account_async(service.store, management_slug)
form = await request_form(request, settings) form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf")) require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"]) hostname = str(account["hostname"])
if not management_account_is_accepted(account): if not management_account_is_accepted(account):
try: try:
await run_in_threadpool(service.publish_initial_dns, request, hostname, myip, ipaddr, myipv6, ip6addr) await run_in_threadpool(
service.publish_initial_dns,
request,
hostname,
ip_params,
)
except BadIpError: except BadIpError:
return HTMLResponse( return HTMLResponse(
_render_pending_management_page( _render_pending_management_page(
service, service,
settings, PendingManagementPageContext(
management_slug, settings=settings,
account, management_slug=management_slug,
dashboard_csrf_token(management_slug, account), account=account,
"The supplied IP address is invalid. Leave IP fields empty and let the router send the first update.", csrf_token=dashboard_csrf_token(management_slug, account),
client_ip(request, settings), message=(
"The supplied IP address is invalid. Leave IP fields empty and let the router "
"send the first update."
),
request_ip=client_ip(request, settings),
),
), ),
status_code=400, status_code=400,
) )
@@ -103,20 +131,22 @@ async def activate_dashboard(
return HTMLResponse( return HTMLResponse(
_render_pending_management_page( _render_pending_management_page(
service, service,
settings, PendingManagementPageContext(
management_slug, settings=settings,
account, management_slug=management_slug,
dashboard_csrf_token(management_slug, account), account=account,
"DNS registration failed. The hostname was not registered.", csrf_token=dashboard_csrf_token(management_slug, account),
client_ip(request, settings), message="DNS registration failed. The hostname was not registered.",
request_ip=client_ip(request, settings),
),
), ),
status_code=500, status_code=500,
) )
activated_account = await run_in_threadpool(lambda: store.activate_account(hostname)) activated_account = await run_in_threadpool(lambda: service.store.activate_account(hostname))
if not activated_account: if not activated_account:
return _not_found() return _not_found()
await run_in_threadpool( await run_in_threadpool(
store.log_update_event, service.store.log_update_event,
hostname, hostname,
None, None,
None, None,
@@ -140,12 +170,14 @@ async def rotate_dashboard_secrets(
return HTMLResponse( return HTMLResponse(
_render_pending_management_page( _render_pending_management_page(
service, service,
settings, PendingManagementPageContext(
management_slug, settings=settings,
account, management_slug=management_slug,
dashboard_csrf_token(management_slug, account), account=account,
"Register this hostname first.", csrf_token=dashboard_csrf_token(management_slug, account),
client_ip(request, settings), message="Register this hostname first.",
request_ip=client_ip(request, settings),
),
), ),
status_code=400, status_code=400,
) )
@@ -171,12 +203,13 @@ async def rotate_dashboard_secrets(
) )
return await render_management_page_async( return await render_management_page_async(
service, service,
settings, ManagementPageContext(
management_slug, management_slug=management_slug,
rotated, account=rotated,
dashboard_csrf_token(management_slug, rotated), csrf_token=dashboard_csrf_token(management_slug, rotated),
message="Update URL changed. Update your router now.", message="Update URL changed. Update your router now.",
request_ip=request_ip, request_ip=request_ip,
),
) )
raise _bad_rotation_action() raise _bad_rotation_action()
+84 -40
View File
@@ -1,5 +1,8 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from fastapi import APIRouter, Request, Response from fastapi import APIRouter, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
@@ -7,9 +10,11 @@ from starlette.concurrency import run_in_threadpool
from ..dependencies import ServiceDep, SettingsDep, StoreDep from ..dependencies import ServiceDep, SettingsDep, StoreDep
from ..event_statuses import DASHBOARD_PASSWORD_CHANGED, DASHBOARD_PASSWORD_ENABLED, DASHBOARD_PASSWORD_REMOVED from ..event_statuses import DASHBOARD_PASSWORD_CHANGED, DASHBOARD_PASSWORD_ENABLED, DASHBOARD_PASSWORD_REMOVED
from ..security import client_ip, dashboard_csrf_token, require_dashboard_csrf from ..security import client_ip, dashboard_csrf_token, require_dashboard_csrf
from ..store import DdnsStore
from ..web.forms import first_form_value, request_form from ..web.forms import first_form_value, request_form
from ..web.templates_management import _render_management_unlock_page from ..web.templates_management import ManagementPageContext, _render_management_unlock_page
from .management_shared import ( from .management_shared import (
ManagementAccount,
delete_unlock_cookie, delete_unlock_cookie,
get_active_management_account_async, get_active_management_account_async,
has_management_access, has_management_access,
@@ -23,6 +28,15 @@ from .path_params import ManagementSlugPath
router = APIRouter() router = APIRouter()
@dataclass(frozen=True, slots=True)
class _DashboardPasswordErrorContext:
management_slug: str
account: ManagementAccount
form: dict[str, list[str]]
request_ip: str
current_password_error: str | None
@router.post("/m/{management_slug}/unlock", include_in_schema=False) @router.post("/m/{management_slug}/unlock", include_in_schema=False)
async def unlock_dashboard( async def unlock_dashboard(
management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep
@@ -70,45 +84,21 @@ async def set_dashboard_password(
form = await request_form(request, settings) form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf")) require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"]) hostname = str(account["hostname"])
if account.get("dashboard_password_hash"): current_password_error = await _verify_current_dashboard_password(store, hostname, account, form)
is_current = await run_in_threadpool( request_ip = client_ip(request, settings)
store.verify_dashboard_password, error_response = await _dashboard_password_error_response(
hostname, service,
first_form_value(form, "current_dashboard_password"), _DashboardPasswordErrorContext(
) management_slug=management_slug,
if not is_current: account=account,
return await management_error_response_async( form=form,
service, request_ip=request_ip,
settings, current_password_error=current_password_error,
management_slug, ),
account, )
"Current password is incorrect.", if error_response:
client_ip(request, settings), return error_response
)
password = first_form_value(form, "dashboard_password") password = first_form_value(form, "dashboard_password")
confirm = first_form_value(form, "dashboard_password_confirm")
if len(password) < 8:
page = await render_management_page_async(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message="Password must be at least 8 characters.",
request_ip=client_ip(request, settings),
)
return HTMLResponse(page, status_code=400)
if password != confirm:
page = await render_management_page_async(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message="The passwords do not match.",
request_ip=client_ip(request, settings),
)
return HTMLResponse(page, status_code=400)
had_password = bool(account.get("dashboard_password_hash")) had_password = bool(account.get("dashboard_password_hash"))
await run_in_threadpool(store.set_dashboard_password, hostname, password) await run_in_threadpool(store.set_dashboard_password, hostname, password)
account = await get_active_management_account_async(store, management_slug) account = await get_active_management_account_async(store, management_slug)
@@ -126,6 +116,61 @@ async def set_dashboard_password(
return response return response
def _dashboard_password_error(password: str, confirm: str) -> str | None:
if len(password) < 8:
return "Password must be at least 8 characters."
if password != confirm:
return "The passwords do not match."
return None
async def _dashboard_password_error_response(
service: ServiceDep,
context: _DashboardPasswordErrorContext,
) -> HTMLResponse | None:
if context.current_password_error:
return await management_error_response_async(
service,
context.management_slug,
context.account,
context.current_password_error,
context.request_ip,
)
password_error = _dashboard_password_error(
first_form_value(context.form, "dashboard_password"),
first_form_value(context.form, "dashboard_password_confirm"),
)
if password_error is None:
return None
page = await render_management_page_async(
service,
ManagementPageContext(
management_slug=context.management_slug,
account=context.account,
csrf_token=dashboard_csrf_token(context.management_slug, context.account),
message=password_error,
request_ip=context.request_ip,
),
)
return HTMLResponse(page, status_code=400)
async def _verify_current_dashboard_password(
store: DdnsStore,
hostname: str,
account: Mapping[str, object],
form: dict[str, list[str]],
) -> str | None:
if not account.get("dashboard_password_hash"):
return None
is_current = await run_in_threadpool(
store.verify_dashboard_password,
hostname,
first_form_value(form, "current_dashboard_password"),
)
return None if is_current else "Current password is incorrect."
@router.post("/m/{management_slug}/password/remove", include_in_schema=False) @router.post("/m/{management_slug}/password/remove", include_in_schema=False)
async def remove_dashboard_password( async def remove_dashboard_password(
management_slug: ManagementSlugPath, management_slug: ManagementSlugPath,
@@ -152,7 +197,6 @@ async def remove_dashboard_password(
if not is_current: if not is_current:
return await management_error_response_async( return await management_error_response_async(
service, service,
settings,
management_slug, management_slug,
account, account,
"Current password is incorrect.", "Current password is incorrect.",
+13 -27
View File
@@ -6,6 +6,7 @@ from starlette.concurrency import run_in_threadpool
from ..security import ( from ..security import (
DASHBOARD_UNLOCK_COOKIE, DASHBOARD_UNLOCK_COOKIE,
DashboardUnlockIdentity,
dashboard_cookie_signing_secret, dashboard_cookie_signing_secret,
dashboard_csrf_token, dashboard_csrf_token,
sign_dashboard_unlock_cookie, sign_dashboard_unlock_cookie,
@@ -15,7 +16,7 @@ from ..services.ddns import DdnsService
from ..settings import DdnsSettings from ..settings import DdnsSettings
from ..store import DdnsStore from ..store import DdnsStore
from ..store.types import AccountRow from ..store.types import AccountRow
from ..web.templates_management import _render_management_page, _render_management_unlock_page from ..web.templates_management import ManagementPageContext, _render_management_page, _render_management_unlock_page
ManagementAccount = AccountRow ManagementAccount = AccountRow
@@ -59,7 +60,6 @@ def locked_management_response(management_slug: str, account: ManagementAccount)
def management_error_response( def management_error_response(
service: DdnsService, service: DdnsService,
settings: DdnsSettings,
management_slug: str, management_slug: str,
account: ManagementAccount, account: ManagementAccount,
message: str, message: str,
@@ -68,12 +68,13 @@ def management_error_response(
return HTMLResponse( return HTMLResponse(
_render_management_page( _render_management_page(
service, service,
settings, ManagementPageContext(
management_slug, management_slug=management_slug,
account, account=account,
dashboard_csrf_token(management_slug, account), csrf_token=dashboard_csrf_token(management_slug, account),
message=message, message=message,
request_ip=request_ip, request_ip=request_ip,
),
), ),
status_code=400, status_code=400,
) )
@@ -81,35 +82,22 @@ def management_error_response(
async def management_error_response_async( async def management_error_response_async(
service: DdnsService, service: DdnsService,
settings: DdnsSettings,
management_slug: str, management_slug: str,
account: ManagementAccount, account: ManagementAccount,
message: str, message: str,
request_ip: str | None = None, request_ip: str | None = None,
) -> HTMLResponse: ) -> HTMLResponse:
return await run_in_threadpool( return await run_in_threadpool(management_error_response, service, management_slug, account, message, request_ip)
management_error_response, service, settings, management_slug, account, message, request_ip
)
async def render_management_page_async( async def render_management_page_async(
service: DdnsService, service: DdnsService,
settings: DdnsSettings, context: ManagementPageContext,
management_slug: str,
account: ManagementAccount,
csrf_token: str,
message: str | None = None,
request_ip: str | None = None,
) -> str: ) -> str:
return await run_in_threadpool( return await run_in_threadpool(
_render_management_page, _render_management_page,
service, service,
settings, context,
management_slug,
account,
csrf_token,
message,
request_ip,
) )
@@ -127,9 +115,7 @@ def set_unlock_cookie(
response.set_cookie( response.set_cookie(
DASHBOARD_UNLOCK_COOKIE, DASHBOARD_UNLOCK_COOKIE,
sign_dashboard_unlock_cookie( sign_dashboard_unlock_cookie(
str(account["hostname"]), DashboardUnlockIdentity(str(account["hostname"]), management_slug, str(password_hash)),
management_slug,
str(password_hash),
signing_secret, signing_secret,
max_age_seconds, max_age_seconds,
), ),
+136 -86
View File
@@ -1,20 +1,36 @@
from __future__ import annotations from __future__ import annotations
import json import json
from collections.abc import Mapping
from typing import Annotated
from urllib.parse import parse_qs from urllib.parse import parse_qs
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from ..dependencies import ServiceDep, StoreDep, apply_update_response from ..dependencies import ServiceDep, apply_update_response
from ..dns import DnsProviderError from ..dns import DnsProviderError
from ..event_statuses import DASHBOARD_HOSTNAME_ACTIVATED from ..event_statuses import DASHBOARD_HOSTNAME_ACTIVATED
from ..security import client_ip from ..security import client_ip
from ..services.ddns import BadIpError from ..services.ddns import BadIpError, DdnsService, UpdateIpParams
from .path_params import ManagementSlugPath, UpdateSlugPath from .path_params import ManagementSlugPath, UpdateSlugPath
router = APIRouter() router = APIRouter()
RouterIpQuery = Annotated[str | None, Query()]
_IpParams = UpdateIpParams
def _ip_params_from_query(
myip: RouterIpQuery = None,
ipaddr: RouterIpQuery = None,
myipv6: RouterIpQuery = None,
ip6addr: RouterIpQuery = None,
) -> UpdateIpParams:
return UpdateIpParams(myip=myip, ipaddr=ipaddr, myipv6=myipv6, ip6addr=ip6addr)
RouterIpParamsDep = Annotated[UpdateIpParams, Depends(_ip_params_from_query)]
@router.get( @router.get(
@@ -27,14 +43,15 @@ router = APIRouter()
async def register_hostname_get( async def register_hostname_get(
dashboard_secret: ManagementSlugPath, dashboard_secret: ManagementSlugPath,
request: Request, request: Request,
store: StoreDep,
service: ServiceDep, service: ServiceDep,
myip: str | None = Query(default=None), ip_params: RouterIpParamsDep,
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
) -> str | PlainTextResponse: ) -> str | PlainTextResponse:
return await _register_hostname(dashboard_secret, request, store, service, myip, ipaddr, myipv6, ip6addr) return await _register_hostname(
dashboard_secret,
request,
service,
ip_params,
)
@router.post( @router.post(
@@ -47,45 +64,76 @@ async def register_hostname_get(
async def register_hostname_post( async def register_hostname_post(
dashboard_secret: ManagementSlugPath, dashboard_secret: ManagementSlugPath,
request: Request, request: Request,
store: StoreDep,
service: ServiceDep, service: ServiceDep,
myip: str | None = Query(default=None), ip_params: RouterIpParamsDep,
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
) -> str | PlainTextResponse: ) -> str | PlainTextResponse:
return await _register_hostname(dashboard_secret, request, store, service, myip, ipaddr, myipv6, ip6addr) return await _register_hostname(
dashboard_secret,
request,
service,
ip_params,
)
async def _register_hostname( async def _register_hostname(
dashboard_secret: str, dashboard_secret: str,
request: Request, request: Request,
store: StoreDep,
service: ServiceDep, service: ServiceDep,
myip: str | None, ip_params: _IpParams,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> str | PlainTextResponse: ) -> str | PlainTextResponse:
myip, ipaddr, myipv6, ip6addr = await _ip_params_from_request(request, myip, ipaddr, myipv6, ip6addr) ip_params = await _ip_params_from_request(request, ip_params)
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret) account = await run_in_threadpool(service.store.get_account_by_management_slug, dashboard_secret)
if not account or account["disabled"]: hostname = _registration_hostname(account, service)
assert account is not None
dns_error = await _publish_initial_registration_dns(request, service, account, hostname, ip_params)
if dns_error is not None:
return dns_error
activated = await run_in_threadpool(service.store.activate_account, hostname)
if not activated:
raise HTTPException(status_code=404, detail="not found")
await _log_registration_activation(request, service, hostname)
return "good registered"
def _registration_hostname(account: Mapping[str, object] | None, service: DdnsService) -> str:
if not account or account.get("disabled"):
raise HTTPException(status_code=404, detail="not found") raise HTTPException(status_code=404, detail="not found")
hostname = str(account["hostname"]) hostname = str(account["hostname"])
if not service.hostname_is_managed(hostname): if not service.hostname_is_managed(hostname):
raise HTTPException(status_code=404, detail="not found") raise HTTPException(status_code=404, detail="not found")
if not account["accepted_at"]: return hostname
try:
await run_in_threadpool(service.publish_initial_dns, request, hostname, myip, ipaddr, myipv6, ip6addr)
except BadIpError as exc: async def _publish_initial_registration_dns(
return PlainTextResponse(f"badip {exc}", status_code=400) request: Request,
except DnsProviderError: service: DdnsService,
return PlainTextResponse("911 dns registration failed", status_code=500) account: Mapping[str, object],
activated = await run_in_threadpool(store.activate_account, hostname) hostname: str,
if not activated: ip_params: _IpParams,
raise HTTPException(status_code=404, detail="not found") ) -> PlainTextResponse | None:
if account.get("accepted_at"):
return None
try:
await run_in_threadpool(
service.publish_initial_dns,
request,
hostname,
ip_params,
)
except BadIpError as exc:
return PlainTextResponse(f"badip {exc}", status_code=400)
except DnsProviderError:
return PlainTextResponse("911 dns registration failed", status_code=500)
return None
async def _log_registration_activation(
request: Request,
service: DdnsService,
hostname: str,
) -> None:
await run_in_threadpool( await run_in_threadpool(
store.log_update_event, service.store.log_update_event,
hostname, hostname,
None, None,
None, None,
@@ -93,7 +141,6 @@ async def _register_hostname(
"hostname registered", "hostname registered",
client_ip(request, service.settings), client_ip(request, service.settings),
) )
return "good registered"
@router.get( @router.get(
@@ -106,14 +153,15 @@ async def _register_hostname(
async def update_hostname_get( async def update_hostname_get(
update_slug: UpdateSlugPath, update_slug: UpdateSlugPath,
request: Request, request: Request,
store: StoreDep,
service: ServiceDep, service: ServiceDep,
myip: str | None = Query(default=None), ip_params: RouterIpParamsDep,
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
) -> str | PlainTextResponse: ) -> str | PlainTextResponse:
return await _update_hostname(update_slug, request, store, service, myip, ipaddr, myipv6, ip6addr) return await _update_hostname(
update_slug,
request,
service,
ip_params,
)
@router.post( @router.post(
@@ -126,76 +174,78 @@ async def update_hostname_get(
async def update_hostname_post( async def update_hostname_post(
update_slug: UpdateSlugPath, update_slug: UpdateSlugPath,
request: Request, request: Request,
store: StoreDep,
service: ServiceDep, service: ServiceDep,
myip: str | None = Query(default=None), ip_params: RouterIpParamsDep,
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
) -> str | PlainTextResponse: ) -> str | PlainTextResponse:
return await _update_hostname(update_slug, request, store, service, myip, ipaddr, myipv6, ip6addr) return await _update_hostname(
update_slug,
request,
service,
ip_params,
)
async def _update_hostname( async def _update_hostname(
update_slug: str, update_slug: str,
request: Request, request: Request,
store: StoreDep,
service: ServiceDep, service: ServiceDep,
myip: str | None, ip_params: _IpParams,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> str | PlainTextResponse: ) -> str | PlainTextResponse:
myip, ipaddr, myipv6, ip6addr = await _ip_params_from_request(request, myip, ipaddr, myipv6, ip6addr) ip_params = await _ip_params_from_request(request, ip_params)
account = await run_in_threadpool(store.get_account_by_slug, update_slug) account = await run_in_threadpool(service.store.get_account_by_slug, update_slug)
if not account or account["disabled"] or not account["accepted_at"]: if not account or account["disabled"] or not account["accepted_at"]:
raise HTTPException(status_code=404, detail="not found") raise HTTPException(status_code=404, detail="not found")
if not service.hostname_is_managed(str(account["hostname"])): if not service.hostname_is_managed(str(account["hostname"])):
raise HTTPException(status_code=404, detail="not found") raise HTTPException(status_code=404, detail="not found")
return await run_in_threadpool( return await run_in_threadpool(
apply_update_response, service, request, str(account["hostname"]), myip, ipaddr, myipv6, ip6addr apply_update_response,
service,
request,
str(account["hostname"]),
ip_params,
) )
async def _ip_params_from_request( async def _ip_params_from_request(request: Request, ip_params: _IpParams) -> _IpParams:
request: Request,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> tuple[str | None, str | None, str | None, str | None]:
if request.method != "POST": if request.method != "POST":
return myip, ipaddr, myipv6, ip6addr return ip_params
body_values = await _post_values(request) body_values = await _post_values(request)
return ( return ip_params.with_body_values(body_values)
myip or body_values.get("myip"),
ipaddr or body_values.get("ipaddr"),
myipv6 or body_values.get("myipv6"),
ip6addr or body_values.get("ip6addr"),
)
async def _post_values(request: Request) -> dict[str, str]: async def _post_values(request: Request) -> dict[str, str]:
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
body = await request.body() body = await request.body()
if not body: if not body:
return {} return {}
content_type = _request_content_type(request)
if content_type == "application/json": if content_type == "application/json":
try: return _json_post_values(body)
data = json.loads(body)
except json.JSONDecodeError:
return {}
if not isinstance(data, dict):
return {}
return {str(key): str(value) for key, value in data.items() if isinstance(value, str)}
if content_type == "application/x-www-form-urlencoded": if content_type == "application/x-www-form-urlencoded":
try: return _form_post_values(body)
form_body = body.decode("utf-8", errors="strict")
except UnicodeDecodeError:
return {}
try:
parsed = parse_qs(form_body, keep_blank_values=True, max_num_fields=8)
except ValueError:
return {}
return {key: values[0] for key, values in parsed.items() if values}
return {} return {}
def _request_content_type(request: Request) -> str:
return request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
def _json_post_values(body: bytes) -> dict[str, str]:
try:
data = json.loads(body)
except json.JSONDecodeError:
return {}
if not isinstance(data, dict):
return {}
return {str(key): value for key, value in data.items() if isinstance(value, str)}
def _form_post_values(body: bytes) -> dict[str, str]:
try:
form_body = body.decode("utf-8", errors="strict")
except UnicodeDecodeError:
return {}
try:
parsed = parse_qs(form_body, keep_blank_values=True, max_num_fields=8)
except ValueError:
return {}
return {key: values[0] for key, values in parsed.items() if values}
+42 -24
View File
@@ -153,16 +153,14 @@ def _hostname_status(
account: Mapping[str, object], account: Mapping[str, object],
history: Sequence[Mapping[str, object]] | None = None, history: Sequence[Mapping[str, object]] | None = None,
) -> HostnameStatusResponse: ) -> HostnameStatusResponse:
account_for_url = {"update_slug": str(account["update_slug"])}
account_for_dashboard = {"management_slug": str(account.get("management_slug", ""))}
return HostnameStatusResponse( return HostnameStatusResponse(
hostname=str(account["hostname"]), hostname=str(account["hostname"]),
status="active" if account.get("accepted_at") else "pending", status=_account_status(account, include_disabled=False),
update_url=service.fritz_update_url(account_for_url), update_url=service.fritz_update_url({"update_slug": str(account["update_slug"])}),
dashboard_url=service.dashboard_url(account_for_dashboard) if account_for_dashboard["management_slug"] else "", dashboard_url=_dashboard_url(service, account),
ipv4=str(account["ipv4"]) if account.get("ipv4") else None, ipv4=_row_text(account, "ipv4"),
ipv6=str(account["ipv6"]) if account.get("ipv6") else None, ipv6=_row_text(account, "ipv6"),
updated_at=str(account["updated_at"]) if account.get("updated_at") else None, updated_at=_row_text(account, "updated_at"),
history=[_hostname_update_event(event) for event in history or []], history=[_hostname_update_event(event) for event in history or []],
) )
@@ -181,8 +179,8 @@ def _hostname_rotate_update_response(
def _hostname_update_event(row: Mapping[str, object]) -> HostnameUpdateEvent: def _hostname_update_event(row: Mapping[str, object]) -> HostnameUpdateEvent:
return HostnameUpdateEvent( return HostnameUpdateEvent(
hostname=str(row["hostname"]), hostname=str(row["hostname"]),
ipv4=str(row["ipv4"]) if row.get("ipv4") else None, ipv4=_row_text(row, "ipv4"),
ipv6=str(row["ipv6"]) if row.get("ipv6") else None, ipv6=_row_text(row, "ipv6"),
status=str(row["status"]), status=str(row["status"]),
detail=str(row["detail"]), detail=str(row["detail"]),
created_at=str(row["created_at"]), created_at=str(row["created_at"]),
@@ -193,12 +191,12 @@ def _hostname_update_event(row: Mapping[str, object]) -> HostnameUpdateEvent:
def _admin_hostname(account: Mapping[str, object]) -> AdminHostname: def _admin_hostname(account: Mapping[str, object]) -> AdminHostname:
return AdminHostname( return AdminHostname(
hostname=str(account["hostname"]), hostname=str(account["hostname"]),
status="disabled" if account.get("disabled") else ("active" if account.get("accepted_at") else "pending"), status=_account_status(account),
ipv4=str(account["ipv4"]) if account.get("ipv4") else None, ipv4=_row_text(account, "ipv4"),
ipv6=str(account["ipv6"]) if account.get("ipv6") else None, ipv6=_row_text(account, "ipv6"),
updated_at=str(account["updated_at"]) if account.get("updated_at") else None, updated_at=_row_text(account, "updated_at"),
created_at=str(account["created_at"]) if account.get("created_at") else None, created_at=_row_text(account, "created_at"),
accepted_at=str(account["accepted_at"]) if account.get("accepted_at") else None, accepted_at=_row_text(account, "accepted_at"),
disabled=bool(account.get("disabled")), disabled=bool(account.get("disabled")),
) )
@@ -212,8 +210,8 @@ def _admin_metrics(metrics: dict[str, float]) -> AdminMetrics:
def _admin_record(row: Mapping[str, object]) -> AdminRecord: def _admin_record(row: Mapping[str, object]) -> AdminRecord:
return AdminRecord( return AdminRecord(
hostname=str(row["hostname"]), hostname=str(row["hostname"]),
ipv4=str(row["ipv4"]) if row.get("ipv4") else None, ipv4=_row_text(row, "ipv4"),
ipv6=str(row["ipv6"]) if row.get("ipv6") else None, ipv6=_row_text(row, "ipv6"),
updated_at=str(row["updated_at"]), updated_at=str(row["updated_at"]),
) )
@@ -221,8 +219,8 @@ def _admin_record(row: Mapping[str, object]) -> AdminRecord:
def _admin_update_event(row: Mapping[str, object]) -> AdminUpdateEvent: def _admin_update_event(row: Mapping[str, object]) -> AdminUpdateEvent:
return AdminUpdateEvent( return AdminUpdateEvent(
hostname=str(row["hostname"]), hostname=str(row["hostname"]),
ipv4=str(row["ipv4"]) if row.get("ipv4") else None, ipv4=_row_text(row, "ipv4"),
ipv6=str(row["ipv6"]) if row.get("ipv6") else None, ipv6=_row_text(row, "ipv6"),
status=str(row["status"]), status=str(row["status"]),
detail=str(row["detail"]), detail=str(row["detail"]),
source_ip=str(row["source_ip"]), source_ip=str(row["source_ip"]),
@@ -243,16 +241,36 @@ def _admin_scheduled_task(row: Mapping[str, object]) -> AdminScheduledTask:
name=str(row["name"]), name=str(row["name"]),
interval_seconds=_int_value(row["interval_seconds"], 0), interval_seconds=_int_value(row["interval_seconds"], 0),
next_run_at=str(row["next_run_at"]), next_run_at=str(row["next_run_at"]),
lease_owner=str(row["lease_owner"]) if row.get("lease_owner") else None, lease_owner=_row_text(row, "lease_owner"),
lease_expires_at=str(row["lease_expires_at"]) if row.get("lease_expires_at") else None, lease_expires_at=_row_text(row, "lease_expires_at"),
last_started_at=str(row["last_started_at"]) if row.get("last_started_at") else None, last_started_at=_row_text(row, "last_started_at"),
last_finished_at=str(row["last_finished_at"]) if row.get("last_finished_at") else None, last_finished_at=_row_text(row, "last_finished_at"),
last_status=str(row["last_status"]), last_status=str(row["last_status"]),
last_error=str(row["last_error"]), last_error=str(row["last_error"]),
updated_at=str(row["updated_at"]), updated_at=str(row["updated_at"]),
) )
def _row_text(row: Mapping[str, object], key: str) -> str | None:
value = row.get(key)
return str(value) if value else None
def _account_status(account: Mapping[str, object], *, include_disabled: bool = True) -> str:
if include_disabled and account.get("disabled"):
return "disabled"
if account.get("accepted_at"):
return "active"
return "pending"
def _dashboard_url(service: DdnsService, account: Mapping[str, object]) -> str:
management_slug = _row_text(account, "management_slug")
if management_slug is None:
return ""
return service.dashboard_url({"management_slug": management_slug})
def _int_value(value: object, default: int) -> int: def _int_value(value: object, default: int) -> int:
if value is None: if value is None:
return default return default
+98 -45
View File
@@ -6,6 +6,7 @@ import hmac
import secrets import secrets
import time import time
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from ipaddress import ip_address, ip_network from ipaddress import ip_address, ip_network
@@ -32,6 +33,23 @@ PRIVATE_PROXY_NETWORKS = tuple(
) )
@dataclass(frozen=True, slots=True)
class _DashboardUnlockCookieParts:
timestamp: int
max_age_seconds: int
signature: str
@dataclass(frozen=True, slots=True)
class DashboardUnlockIdentity:
hostname: str
management_slug: str
password_hash: str
def payload(self, timestamp: int, max_age_seconds: int) -> bytes:
return f"{self.hostname}\0{self.management_slug}\0{self.password_hash}\0{timestamp}\0{max_age_seconds}".encode()
def credentials_from_basic_auth(header: str) -> tuple[str | None, str | None]: def credentials_from_basic_auth(header: str) -> tuple[str | None, str | None]:
try: try:
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8") raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
@@ -69,9 +87,7 @@ def new_bearer_secret() -> str:
def sign_dashboard_unlock_cookie( def sign_dashboard_unlock_cookie(
hostname: str, identity: DashboardUnlockIdentity,
management_slug: str,
password_hash: str,
signing_secret: str, signing_secret: str,
max_age_seconds: int, max_age_seconds: int,
now: int | None = None, now: int | None = None,
@@ -79,9 +95,7 @@ def sign_dashboard_unlock_cookie(
timestamp = int(time.time() if now is None else now) timestamp = int(time.time() if now is None else now)
bounded_max_age = _bounded_dashboard_unlock_seconds(max_age_seconds) bounded_max_age = _bounded_dashboard_unlock_seconds(max_age_seconds)
signature = _dashboard_unlock_signature( signature = _dashboard_unlock_signature(
hostname, identity,
management_slug,
password_hash,
signing_secret, signing_secret,
timestamp, timestamp,
bounded_max_age, bounded_max_age,
@@ -96,40 +110,63 @@ def verify_dashboard_unlock_cookie(
signing_secret: str, signing_secret: str,
cookie_value: str | None, cookie_value: str | None,
) -> bool: ) -> bool:
if not password_hash or not signing_secret or not cookie_value: identity = _dashboard_unlock_identity(hostname, management_slug, password_hash)
parts = _active_dashboard_unlock_cookie_parts(cookie_value)
if identity is None or not signing_secret or parts is None:
return False return False
expected = _dashboard_unlock_signature(
identity,
signing_secret,
parts.timestamp,
parts.max_age_seconds,
)
return hmac.compare_digest(parts.signature, expected)
def _dashboard_unlock_identity(
hostname: str,
management_slug: str,
password_hash: str | None,
) -> DashboardUnlockIdentity | None:
if not password_hash:
return None
return DashboardUnlockIdentity(hostname, management_slug, password_hash)
def _active_dashboard_unlock_cookie_parts(cookie_value: str | None) -> _DashboardUnlockCookieParts | None:
if cookie_value is None:
return None
parts = _dashboard_unlock_cookie_parts(cookie_value)
if parts is None:
return None
if _dashboard_unlock_cookie_expired(parts, int(time.time())):
return None
return parts
def _dashboard_unlock_cookie_parts(cookie_value: str) -> _DashboardUnlockCookieParts | None:
try: try:
version, raw_timestamp, raw_max_age, supplied = cookie_value.split(":", 3) version, raw_timestamp, raw_max_age, signature = cookie_value.split(":", 3)
timestamp = int(raw_timestamp) timestamp = int(raw_timestamp)
max_age_seconds = _bounded_dashboard_unlock_seconds(int(raw_max_age)) max_age_seconds = _bounded_dashboard_unlock_seconds(int(raw_max_age))
except ValueError: except ValueError:
return False return None
if version != "v3": if version != "v3":
return False return None
now = int(time.time()) return _DashboardUnlockCookieParts(timestamp, max_age_seconds, signature)
if timestamp > now + 60 or now - timestamp > max_age_seconds:
return False
expected = _dashboard_unlock_signature( def _dashboard_unlock_cookie_expired(parts: _DashboardUnlockCookieParts, now: int) -> bool:
hostname, return parts.timestamp > now + 60 or now - parts.timestamp > parts.max_age_seconds
management_slug,
password_hash,
signing_secret,
timestamp,
max_age_seconds,
)
return hmac.compare_digest(supplied, expected)
def _dashboard_unlock_signature( def _dashboard_unlock_signature(
hostname: str, identity: DashboardUnlockIdentity,
management_slug: str,
password_hash: str,
signing_secret: str, signing_secret: str,
timestamp: int, timestamp: int,
max_age_seconds: int, max_age_seconds: int,
) -> str: ) -> str:
payload = f"{hostname}\0{management_slug}\0{password_hash}\0{timestamp}\0{max_age_seconds}".encode() return hmac.new(signing_secret.encode(), identity.payload(timestamp, max_age_seconds), hashlib.sha256).hexdigest()
return hmac.new(signing_secret.encode(), payload, hashlib.sha256).hexdigest()
def dashboard_cookie_signing_secret(settings: DdnsSettings) -> str: def dashboard_cookie_signing_secret(settings: DdnsSettings) -> str:
@@ -186,19 +223,20 @@ def rate_limit_bucket(path: str) -> str:
def _trusted_proxy(peer: str, trusted: set[str]) -> bool: def _trusted_proxy(peer: str, trusted: set[str]) -> bool:
if not trusted: if not trusted:
return False return False
return any(_trusted_proxy_candidate(peer, candidate) for candidate in trusted)
def _trusted_proxy_candidate(peer: str, candidate: str) -> bool:
try: try:
peer_ip = ip_address(peer) peer_ip = ip_address(peer)
except ValueError: except ValueError:
return False return False
for candidate in trusted: try:
try: if "/" in candidate:
if "/" in candidate and peer_ip in ip_network(candidate, strict=False): return peer_ip in ip_network(candidate, strict=False)
return True return peer_ip == ip_address(candidate)
if "/" not in candidate and peer_ip == ip_address(candidate): except ValueError:
return True return False
except ValueError:
continue
return False
def _should_trust_proxy_headers(peer: str, settings: DdnsSettings | None) -> bool: def _should_trust_proxy_headers(peer: str, settings: DdnsSettings | None) -> bool:
@@ -250,13 +288,28 @@ def _valid_header_ip(value: str) -> str | None:
def _strip_header_ip(value: str) -> str: def _strip_header_ip(value: str) -> str:
cleaned = value.strip().strip('"').strip("'") cleaned = _strip_header_ip_quotes(value)
if not cleaned or cleaned.lower() == "unknown": if _empty_header_ip(cleaned):
return "" return ""
if cleaned.startswith("[") and "]" in cleaned: return _strip_bracketed_ip(cleaned) or _strip_host_port(cleaned) or cleaned
return cleaned[1 : cleaned.index("]")]
if cleaned.count(":") == 1:
host, port = cleaned.rsplit(":", 1) def _strip_header_ip_quotes(value: str) -> str:
if port.isdigit(): return value.strip().strip('"').strip("'")
return host
return cleaned
def _empty_header_ip(value: str) -> bool:
return not value or value.lower() == "unknown"
def _strip_bracketed_ip(value: str) -> str:
if value.startswith("[") and "]" in value:
return value[1 : value.index("]")]
return ""
def _strip_host_port(value: str) -> str:
if value.count(":") != 1:
return ""
host, port = value.rsplit(":", 1)
return host if port.isdigit() else ""
+112 -49
View File
@@ -31,6 +31,35 @@ class UpdateOutcome:
source_ip: str source_ip: str
@dataclass(frozen=True, slots=True)
class UpdateIpParams:
myip: str | None = None
ipaddr: str | None = None
myipv6: str | None = None
ip6addr: str | None = None
@property
def initial_plan_args(self) -> tuple[str | None, str | None, str | None, str | None]:
return self.myip, self.ipaddr, self.myipv6, self.ip6addr
def with_body_values(self, body_values: Mapping[str, str]) -> UpdateIpParams:
return UpdateIpParams(
myip=self.myip or body_values.get("myip"),
ipaddr=self.ipaddr or body_values.get("ipaddr"),
myipv6=self.myipv6 or body_values.get("myipv6"),
ip6addr=self.ip6addr or body_values.get("ip6addr"),
)
@dataclass(frozen=True, slots=True)
class InitialDnsPlan:
ipv4: str | None
ipv6: str | None
update_ipv4: bool
update_ipv6: bool
detail: str
class BadIpError(ValueError): class BadIpError(ValueError):
"""Raised when a supplied DynDNS IP parameter is invalid.""" """Raised when a supplied DynDNS IP parameter is invalid."""
@@ -71,36 +100,21 @@ class DdnsService:
self, self,
request: Request, request: Request,
hostname: str, hostname: str,
myip: str | None, ip_params: UpdateIpParams,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> bool: ) -> bool:
if not dns_backend_configured(self.settings): if not dns_backend_configured(self.settings):
return False return False
source_ip = client_ip(request, self.settings) source_ip = client_ip(request, self.settings)
source_fallback = source_ip if _is_global_ip(source_ip) else "" plan = _initial_dns_plan(source_ip, *ip_params.initial_plan_args)
supplied_ip = any(value and value.strip() for value in (myip, ipaddr, myipv6, ip6addr))
ipv4: str | None
ipv6: str | None
if not supplied_ip and not source_fallback:
ipv4 = INITIAL_PLACEHOLDER_IPV4
ipv6 = None
update_ipv4 = True
update_ipv6 = False
detail = "initial placeholder dns registered"
else:
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
source_fallback,
myip,
ipaddr,
myipv6,
ip6addr,
)
detail = "initial dns registered"
lock = self._lock_for(hostname) lock = self._lock_for(hostname)
with lock: with lock:
planned = self.store.preview_update(hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6) planned = self.store.preview_update(
hostname,
plan.ipv4,
plan.ipv6,
update_ipv4=plan.update_ipv4,
update_ipv6=plan.update_ipv6,
)
if planned.changed: if planned.changed:
try: try:
publish_dns(planned, self.settings) publish_dns(planned, self.settings)
@@ -113,7 +127,12 @@ class DdnsService:
type(exc).__name__, type(exc).__name__,
) )
self.store.log_update_event( self.store.log_update_event(
hostname, ipv4, ipv6, DNS_RECONCILE_FAILED, f"{type(exc).__name__}: {exc}", source_ip hostname,
plan.ipv4,
plan.ipv6,
DNS_RECONCILE_FAILED,
f"{type(exc).__name__}: {exc}",
source_ip,
) )
raise raise
except DnsProviderError as exc: except DnsProviderError as exc:
@@ -125,21 +144,23 @@ class DdnsService:
type(exc).__name__, type(exc).__name__,
) )
self.store.log_update_event( self.store.log_update_event(
hostname, ipv4, ipv6, DNS_FAILED, f"{type(exc).__name__}: {exc}", source_ip hostname,
plan.ipv4,
plan.ipv6,
DNS_FAILED,
f"{type(exc).__name__}: {exc}",
source_ip,
) )
raise raise
result = self.store.commit_update_result(planned) result = self.store.commit_update_result(planned)
self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, detail, source_ip) self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, plan.detail, source_ip)
return True return True
def apply_update( def apply_update(
self, self,
request: Request, request: Request,
hostname: str, hostname: str,
myip: str | None, ip_params: UpdateIpParams,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> UpdateOutcome: ) -> UpdateOutcome:
started_at = time.perf_counter() started_at = time.perf_counter()
is_success = False is_success = False
@@ -150,10 +171,7 @@ class DdnsService:
try: try:
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips( ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
source_ip, source_ip,
myip, *ip_params.initial_plan_args,
ipaddr,
myipv6,
ip6addr,
) )
lock = self._lock_for(hostname) lock = self._lock_for(hostname)
with lock: with lock:
@@ -248,13 +266,8 @@ class DdnsService:
def normalize_ip(value: str | None, version: int | None = None) -> str | None: def normalize_ip(value: str | None, version: int | None = None) -> str | None:
if not value or not value.strip(): ip = _optional_ip(value)
return None if ip is None or (version and ip.version != version):
try:
ip = ipaddress.ip_address(value.strip())
except ValueError:
return None
if version and ip.version != version:
return None return None
return str(ip) return str(ip)
@@ -280,6 +293,42 @@ def parse_update_ips(
return ipv4, ipv6, update_ipv4, update_ipv6 return ipv4, ipv6, update_ipv4, update_ipv6
def _initial_dns_plan(
source_ip: str,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> InitialDnsPlan:
source_fallback = source_ip if _is_global_ip(source_ip) else ""
if not _has_supplied_ip(myip, ipaddr, myipv6, ip6addr) and not source_fallback:
return InitialDnsPlan(
ipv4=INITIAL_PLACEHOLDER_IPV4,
ipv6=None,
update_ipv4=True,
update_ipv6=False,
detail="initial placeholder dns registered",
)
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
source_fallback,
myip,
ipaddr,
myipv6,
ip6addr,
)
return InitialDnsPlan(
ipv4=ipv4,
ipv6=ipv6,
update_ipv4=update_ipv4,
update_ipv6=update_ipv6,
detail="initial dns registered",
)
def _has_supplied_ip(*values: str | None) -> bool:
return any(value and value.strip() for value in values)
def _is_global_ip(value: str | None) -> bool: def _is_global_ip(value: str | None) -> bool:
if not value: if not value:
return False return False
@@ -294,17 +343,31 @@ def _first_valid_ip(params: tuple[tuple[str, str | None], ...], version: int) ->
for name, value in params: for name, value in params:
if value is None or not value.strip(): if value is None or not value.strip():
continue continue
try: ip_value = _required_ip_param(name, value, version)
ip = ipaddress.ip_address(value.strip())
except ValueError as exc:
raise BadIpError(f"{name} must be a valid IPv{version} address") from exc
if ip.version != version:
raise BadIpError(f"{name} must be an IPv{version} address")
if selected is None: if selected is None:
selected = str(ip) selected = ip_value
return selected return selected
def _optional_ip(value: str | None) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
if not value or not value.strip():
return None
try:
return ipaddress.ip_address(value.strip())
except ValueError:
return None
def _required_ip_param(name: str, value: str, version: int) -> str:
try:
ip = ipaddress.ip_address(value.strip())
except ValueError as exc:
raise BadIpError(f"{name} must be a valid IPv{version} address") from exc
if ip.version != version:
raise BadIpError(f"{name} must be an IPv{version} address")
return str(ip)
def normalize_hostname(value: str, settings: DdnsSettings, expand_suffix: bool = True) -> str: def normalize_hostname(value: str, settings: DdnsSettings, expand_suffix: bool = True) -> str:
hostname = value.strip().lower().rstrip(".") hostname = value.strip().lower().rstrip(".")
suffix = settings.hostname_suffix.strip().lower().strip(".") suffix = settings.hostname_suffix.strip().lower().strip(".")
+70 -44
View File
@@ -6,6 +6,34 @@ from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
_RESERVED_PUBLIC_PATHS = {
"/admin.css",
"/admin-tables.js",
"/api",
"/app.js",
"/clipboard.js",
"/docs",
"/favicon.ico",
"/healthz",
"/install.sh",
"/bootstrap-subset.css",
"/logo.png",
"/logo-nav.webp",
"/logo.webp",
"/m",
"/openapi.json",
"/privacy",
"/redoc",
"/robots.txt",
"/sitemap.xml",
"/site.webmanifest",
"/social-preview.png",
"/social-preview.webp",
"/theme.js",
"/uninstall.sh",
"/visitor-ip.js",
}
class DdnsSettings(BaseModel): class DdnsSettings(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -42,52 +70,25 @@ class DdnsSettings(BaseModel):
@field_validator("public_base_url") @field_validator("public_base_url")
@classmethod @classmethod
def validate_public_base_url(cls, value: str) -> str: def validate_public_base_url(_cls, value: str) -> str:
if not value.startswith(("http://", "https://")): if not value.startswith(("http://", "https://")):
raise ValueError("public_base_url must start with http:// or https://") raise ValueError("public_base_url must start with http:// or https://")
return value.rstrip("/") return value.rstrip("/")
@field_validator("trusted_hosts") @field_validator("trusted_hosts")
@classmethod @classmethod
def validate_trusted_hosts(cls, value: set[str]) -> set[str]: def validate_trusted_hosts(_cls, value: set[str]) -> set[str]:
return {host.strip().lower() for host in value if host.strip()} return {host.strip().lower() for host in value if host.strip()}
@field_validator("admin_path") @field_validator("admin_path")
@classmethod @classmethod
def validate_admin_path(cls, value: str) -> str: def validate_admin_path(_cls, value: str) -> str:
path = value.strip() path = value.strip()
if not path.startswith("/") or path == "/" or path.endswith("/"): if not _has_valid_admin_path_shape(path):
raise ValueError("admin_path must start with / and must not be / or end with /") raise ValueError("admin_path must start with / and must not be / or end with /")
if not re.fullmatch(r"/[A-Za-z0-9._~/-]+", path): if not _has_url_safe_path_chars(path):
raise ValueError("admin_path may only contain URL-safe path characters") raise ValueError("admin_path may only contain URL-safe path characters")
reserved_paths = { if _is_reserved_admin_path(path):
"/admin.css",
"/admin-tables.js",
"/api",
"/app.js",
"/clipboard.js",
"/docs",
"/favicon.ico",
"/healthz",
"/install.sh",
"/bootstrap-subset.css",
"/logo.png",
"/logo-nav.webp",
"/logo.webp",
"/m",
"/openapi.json",
"/privacy",
"/redoc",
"/robots.txt",
"/sitemap.xml",
"/site.webmanifest",
"/social-preview.png",
"/social-preview.webp",
"/theme.js",
"/uninstall.sh",
"/visitor-ip.js",
}
if any(path == reserved or path.startswith(f"{reserved}/") for reserved in reserved_paths):
raise ValueError("admin_path must not conflict with a public route") raise ValueError("admin_path must not conflict with a public route")
return path return path
@@ -107,19 +108,44 @@ class DdnsSettings(BaseModel):
validate_dns_provider_settings(self) validate_dns_provider_settings(self)
if not self.require_dns_provider: if not self.require_dns_provider:
return return
if len(self.admin_password) < 16: _validate_launch_secrets(self.admin_password, self.dashboard_cookie_secret)
raise RuntimeError("DDNS_ADMIN_PASSWORD must be at least 16 characters when DDNS_REQUIRE_DNS_PROVIDER=1") _validate_launch_hostname_suffix(self.hostname_suffix)
if len(self.dashboard_cookie_secret or self.admin_password) < 32: _validate_launch_trusted_hosts(self.effective_trusted_hosts)
raise RuntimeError(
"DDNS_DASHBOARD_COOKIE_SECRET or DDNS_ADMIN_PASSWORD must be at least 32 characters for dashboard cookie signing when DDNS_REQUIRE_DNS_PROVIDER=1"
)
if not self.hostname_suffix:
raise RuntimeError("DDNS_HOSTNAME_SUFFIX is required when DDNS_REQUIRE_DNS_PROVIDER=1")
if not self.effective_trusted_hosts:
raise RuntimeError("At least one trusted host is required for production")
@classmethod @classmethod
def from_env(cls) -> DdnsSettings: def from_env(_cls) -> DdnsSettings:
from .config import settings_from_env from .config import settings_from_env
return settings_from_env() return settings_from_env()
def _has_valid_admin_path_shape(path: str) -> bool:
return path.startswith("/") and path != "/" and not path.endswith("/")
def _has_url_safe_path_chars(path: str) -> bool:
return re.fullmatch(r"/[A-Za-z0-9._~/-]+", path) is not None
def _is_reserved_admin_path(path: str) -> bool:
return any(path == reserved or path.startswith(f"{reserved}/") for reserved in _RESERVED_PUBLIC_PATHS)
def _validate_launch_secrets(admin_password: str, dashboard_cookie_secret: str) -> None:
if len(admin_password) < 16:
raise RuntimeError("DDNS_ADMIN_PASSWORD must be at least 16 characters when DDNS_REQUIRE_DNS_PROVIDER=1")
if len(dashboard_cookie_secret or admin_password) < 32:
raise RuntimeError(
"DDNS_DASHBOARD_COOKIE_SECRET or DDNS_ADMIN_PASSWORD must be at least 32 characters "
"for dashboard cookie signing when DDNS_REQUIRE_DNS_PROVIDER=1"
)
def _validate_launch_hostname_suffix(hostname_suffix: str) -> None:
if not hostname_suffix:
raise RuntimeError("DDNS_HOSTNAME_SUFFIX is required when DDNS_REQUIRE_DNS_PROVIDER=1")
def _validate_launch_trusted_hosts(effective_trusted_hosts: list[str]) -> None:
if not effective_trusted_hosts:
raise RuntimeError("At least one trusted host is required for production")
+8 -3
View File
@@ -9,6 +9,7 @@ from .types import AccountRow, account_row
class AccountsMixin(StoreConnectionMixin): class AccountsMixin(StoreConnectionMixin):
def create_account(self, hostname: str, username: str | None = None, *, accepted: bool = True) -> AccountRow: def create_account(self, hostname: str, username: str | None = None, *, accepted: bool = True) -> AccountRow:
_ = username
now = datetime.now(UTC).isoformat() now = datetime.now(UTC).isoformat()
update_slug = new_bearer_secret() update_slug = new_bearer_secret()
management_slug = new_bearer_secret() management_slug = new_bearer_secret()
@@ -27,6 +28,7 @@ class AccountsMixin(StoreConnectionMixin):
"update_slug": update_slug, "update_slug": update_slug,
"management_slug": management_slug, "management_slug": management_slug,
"accepted_at": accepted_at, "accepted_at": accepted_at,
"disabled": 0,
} }
def list_accounts(self, limit: int = 100, offset: int = 0) -> list[AccountRow]: def list_accounts(self, limit: int = 100, offset: int = 0) -> list[AccountRow]:
@@ -34,7 +36,8 @@ class AccountsMixin(StoreConnectionMixin):
rows = conn.execute( rows = conn.execute(
""" """
SELECT accounts.hostname, accounts.update_slug, SELECT accounts.hostname, accounts.update_slug,
accounts.created_at, accounts.accepted_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at accounts.created_at, accounts.accepted_at, accounts.disabled,
records.ipv4, records.ipv6, records.updated_at
FROM accounts FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname LEFT JOIN records ON records.hostname = accounts.hostname
ORDER BY accounts.hostname ORDER BY accounts.hostname
@@ -54,7 +57,8 @@ class AccountsMixin(StoreConnectionMixin):
account = conn.execute( account = conn.execute(
""" """
SELECT accounts.hostname, accounts.update_slug, accounts.dashboard_password_hash, SELECT accounts.hostname, accounts.update_slug, accounts.dashboard_password_hash,
accounts.created_at, accounts.accepted_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at accounts.created_at, accounts.accepted_at, accounts.disabled,
records.ipv4, records.ipv6, records.updated_at
FROM accounts FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.hostname = ? WHERE accounts.hostname = ?
@@ -79,7 +83,8 @@ class AccountsMixin(StoreConnectionMixin):
account = conn.execute( account = conn.execute(
""" """
SELECT accounts.hostname, accounts.update_slug, accounts.dashboard_password_hash, SELECT accounts.hostname, accounts.update_slug, accounts.dashboard_password_hash,
accounts.created_at, accounts.accepted_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at accounts.created_at, accounts.accepted_at, accounts.disabled,
records.ipv4, records.ipv6, records.updated_at
FROM accounts FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.management_hash = ? WHERE accounts.management_hash = ?
+30 -13
View File
@@ -5,6 +5,29 @@ from datetime import UTC, datetime
from .base import StoreConnectionMixin from .base import StoreConnectionMixin
def _update_metric_increments(
latency_ms: float,
*,
is_success: bool,
is_dns_failure: bool,
is_reconcile_failure: bool,
is_bad_ip: bool,
) -> dict[str, float]:
increments: dict[str, float] = {
"update_requests_total": 1,
"update_latency_ms_total": latency_ms,
"update_latency_samples_total": 1,
}
optional_increments = {
"update_success_total": is_success,
"dns_failures_total": is_dns_failure,
"dns_reconcile_failures_total": is_reconcile_failure,
"bad_ip_total": is_bad_ip,
}
increments.update({name: 1 for name, enabled in optional_increments.items() if enabled})
return increments
class MetricsMixin(StoreConnectionMixin): class MetricsMixin(StoreConnectionMixin):
def increment_metric(self, name: str, amount: float = 1.0) -> None: def increment_metric(self, name: str, amount: float = 1.0) -> None:
now = datetime.now(UTC).isoformat() now = datetime.now(UTC).isoformat()
@@ -48,19 +71,13 @@ class MetricsMixin(StoreConnectionMixin):
is_reconcile_failure: bool = False, is_reconcile_failure: bool = False,
is_bad_ip: bool = False, is_bad_ip: bool = False,
) -> None: ) -> None:
increments = { increments = _update_metric_increments(
"update_requests_total": 1, latency_ms,
"update_latency_ms_total": latency_ms, is_success=is_success,
"update_latency_samples_total": 1, is_dns_failure=is_dns_failure,
} is_reconcile_failure=is_reconcile_failure,
if is_success: is_bad_ip=is_bad_ip,
increments["update_success_total"] = 1 )
if is_dns_failure:
increments["dns_failures_total"] = 1
if is_reconcile_failure:
increments["dns_reconcile_failures_total"] = 1
if is_bad_ip:
increments["bad_ip_total"] = 1
now = datetime.now(UTC).isoformat() now = datetime.now(UTC).isoformat()
with self.connect() as conn: with self.connect() as conn:
+5 -3
View File
@@ -199,9 +199,11 @@ class RateLimitCleanupMixin(StoreConnectionMixin):
[ [
( (
row["hostname"], row["hostname"],
CLEANUP_PENDING_HOSTNAME_DELETED (
if row["cleanup_reason"] == "pending" CLEANUP_PENDING_HOSTNAME_DELETED
else CLEANUP_UNUSED_HOSTNAME_DELETED, if row["cleanup_reason"] == "pending"
else CLEANUP_UNUSED_HOSTNAME_DELETED
),
"cleanup deleted abandoned hostname", "cleanup deleted abandoned hostname",
"127.0.0.1", "127.0.0.1",
datetime.now(UTC).isoformat(), datetime.now(UTC).isoformat(),
+93 -42
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from ..models import UpdateResult from ..models import UpdateResult
@@ -8,6 +9,60 @@ from .base import StoreConnectionMixin
from .types import RecordRow, UpdateEventRow, record_row, update_event_row from .types import RecordRow, UpdateEventRow, record_row, update_event_row
@dataclass(frozen=True, slots=True)
class UpdateEventInput:
hostname: str
ipv4: str | None
ipv6: str | None
status: str
detail: str
source_ip: str
@dataclass(frozen=True, slots=True)
class RecordUpdateInput:
hostname: str
ipv4: str | None
ipv6: str | None
update_ipv4: bool = True
update_ipv6: bool = True
def _update_event_input(event: UpdateEventInput | str, legacy_values: tuple[object, ...]) -> UpdateEventInput:
if isinstance(event, UpdateEventInput):
return event
if len(legacy_values) != 5:
raise TypeError("legacy update event values must be ipv4, ipv6, status, detail, source_ip")
ipv4, ipv6, status, detail, source_ip = legacy_values
return UpdateEventInput(
hostname=event,
ipv4=str(ipv4) if ipv4 is not None else None,
ipv6=str(ipv6) if ipv6 is not None else None,
status=str(status),
detail=str(detail),
source_ip=str(source_ip),
)
def _planned_record_update(
update: RecordUpdateInput,
*,
current_ipv4: str | None,
current_ipv6: str | None,
existing: bool,
) -> UpdateResult:
effective_ipv4 = update.ipv4 if update.update_ipv4 else current_ipv4
effective_ipv6 = update.ipv6 if update.update_ipv6 else current_ipv6
return UpdateResult(
hostname=update.hostname,
ipv4=effective_ipv4,
ipv6=effective_ipv6,
changed=not existing or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6,
update_ipv4=update.update_ipv4,
update_ipv6=update.update_ipv6,
)
class RecordsEventsMixin(StoreConnectionMixin): class RecordsEventsMixin(StoreConnectionMixin):
def upsert( def upsert(
self, self,
@@ -18,13 +73,17 @@ class RecordsEventsMixin(StoreConnectionMixin):
update_ipv6: bool = True, update_ipv6: bool = True,
) -> UpdateResult: ) -> UpdateResult:
now = datetime.now(UTC).isoformat() now = datetime.now(UTC).isoformat()
update = RecordUpdateInput(hostname, ipv4, ipv6, update_ipv4, update_ipv6)
with self.connect() as conn: with self.connect() as conn:
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone() existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
current_ipv4 = existing["ipv4"] if existing else None current_ipv4 = existing["ipv4"] if existing else None
current_ipv6 = existing["ipv6"] if existing else None current_ipv6 = existing["ipv6"] if existing else None
effective_ipv4 = ipv4 if update_ipv4 else current_ipv4 result = _planned_record_update(
effective_ipv6 = ipv6 if update_ipv6 else current_ipv6 update,
changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6 current_ipv4=current_ipv4,
current_ipv6=current_ipv6,
existing=existing is not None,
)
conn.execute( conn.execute(
""" """
INSERT INTO records(hostname, ipv4, ipv6, updated_at) INSERT INTO records(hostname, ipv4, ipv6, updated_at)
@@ -34,16 +93,9 @@ class RecordsEventsMixin(StoreConnectionMixin):
ipv6=excluded.ipv6, ipv6=excluded.ipv6,
updated_at=excluded.updated_at updated_at=excluded.updated_at
""", """,
(hostname, effective_ipv4, effective_ipv6, now), (result.hostname, result.ipv4, result.ipv6, now),
) )
return UpdateResult( return result
hostname=hostname,
ipv4=effective_ipv4,
ipv6=effective_ipv6,
changed=changed,
update_ipv4=update_ipv4,
update_ipv6=update_ipv6,
)
def commit_update_result(self, result: UpdateResult) -> UpdateResult: def commit_update_result(self, result: UpdateResult) -> UpdateResult:
now = datetime.now(UTC).isoformat() now = datetime.now(UTC).isoformat()
@@ -69,20 +121,16 @@ class RecordsEventsMixin(StoreConnectionMixin):
update_ipv4: bool = True, update_ipv4: bool = True,
update_ipv6: bool = True, update_ipv6: bool = True,
) -> UpdateResult: ) -> UpdateResult:
update = RecordUpdateInput(hostname, ipv4, ipv6, update_ipv4, update_ipv6)
with self.connect() as conn: with self.connect() as conn:
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone() existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
current_ipv4 = existing["ipv4"] if existing else None current_ipv4 = existing["ipv4"] if existing else None
current_ipv6 = existing["ipv6"] if existing else None current_ipv6 = existing["ipv6"] if existing else None
effective_ipv4 = ipv4 if update_ipv4 else current_ipv4 return _planned_record_update(
effective_ipv6 = ipv6 if update_ipv6 else current_ipv6 update,
changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6 current_ipv4=current_ipv4,
return UpdateResult( current_ipv6=current_ipv6,
hostname=hostname, existing=existing is not None,
ipv4=effective_ipv4,
ipv6=effective_ipv6,
changed=changed,
update_ipv4=update_ipv4,
update_ipv6=update_ipv6,
) )
def list_records(self) -> list[RecordRow]: def list_records(self) -> list[RecordRow]:
@@ -106,33 +154,27 @@ class RecordsEventsMixin(StoreConnectionMixin):
row = conn.execute("SELECT COUNT(*) FROM records").fetchone() row = conn.execute("SELECT COUNT(*) FROM records").fetchone()
return int(row[0]) return int(row[0])
def log_update_event( def log_update_event(self, event: UpdateEventInput | str, *legacy_values: object) -> None:
self, event = _update_event_input(event, legacy_values)
hostname: str,
ipv4: str | None,
ipv6: str | None,
status: str,
detail: str,
source_ip: str,
) -> None:
with self.connect() as conn: with self.connect() as conn:
conn.execute( conn.execute(
""" """
INSERT INTO update_events(hostname, ipv4, ipv6, status, detail, source_ip, created_at) INSERT INTO update_events(hostname, ipv4, ipv6, status, detail, source_ip, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
""", """,
(hostname, ipv4, ipv6, status, detail[:500], source_ip, datetime.now(UTC).isoformat()), (
event.hostname,
event.ipv4,
event.ipv6,
event.status,
event.detail[:500],
event.source_ip,
datetime.now(UTC).isoformat(),
),
) )
def log_update_rollup( def log_update_rollup(self, event: UpdateEventInput | str, *legacy_values: object) -> None:
self, event = _update_event_input(event, legacy_values)
hostname: str,
ipv4: str | None,
ipv6: str | None,
status: str,
detail: str,
source_ip: str,
) -> None:
now = datetime.now(UTC).isoformat() now = datetime.now(UTC).isoformat()
with self.connect() as conn: with self.connect() as conn:
conn.execute( conn.execute(
@@ -145,7 +187,16 @@ class RecordsEventsMixin(StoreConnectionMixin):
last_seen_at=excluded.last_seen_at, last_seen_at=excluded.last_seen_at,
count=count + 1 count=count + 1
""", """,
(hostname, ipv4 or "", ipv6 or "", status, detail[:500], source_ip, now, now), (
event.hostname,
event.ipv4 or "",
event.ipv6 or "",
event.status,
event.detail[:500],
event.source_ip,
now,
now,
),
) )
def list_update_events(self, limit: int = 100, offset: int = 0) -> list[UpdateEventRow]: def list_update_events(self, limit: int = 100, offset: int = 0) -> list[UpdateEventRow]:
+4 -1
View File
@@ -186,7 +186,10 @@ def _migrate_accounts_schema(conn: sqlite3.Connection) -> None:
) )
""") """)
conn.execute(""" conn.execute("""
INSERT INTO accounts_new(hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled) INSERT INTO accounts_new(
hostname, update_slug, management_hash, dashboard_password_hash,
created_at, accepted_at, disabled
)
SELECT hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled SELECT hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled
FROM accounts FROM accounts
""") """)
+12 -12
View File
@@ -1,22 +1,22 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, TypedDict, cast from typing import Any, NotRequired, TypedDict, cast
class AccountRow(TypedDict, total=False): class AccountRow(TypedDict):
hostname: str hostname: str
update_slug: str update_slug: str
management_slug: str
dashboard_password_hash: str | None
created_at: str
accepted_at: str | None accepted_at: str | None
disabled: int disabled: int
ipv4: str | None management_slug: NotRequired[str]
ipv6: str | None dashboard_password_hash: NotRequired[str | None]
updated_at: str | None created_at: NotRequired[str]
ipv4: NotRequired[str | None]
ipv6: NotRequired[str | None]
updated_at: NotRequired[str | None]
class UpdateEventRow(TypedDict, total=False): class UpdateEventRow(TypedDict):
hostname: str hostname: str
ipv4: str | None ipv4: str | None
ipv6: str | None ipv6: str | None
@@ -33,9 +33,9 @@ class CleanupRunRow(TypedDict):
class ScheduledTaskRow(TypedDict, total=False): class ScheduledTaskRow(TypedDict, total=False):
name: str name: NotRequired[str]
interval_seconds: int interval_seconds: NotRequired[int]
next_run_at: str next_run_at: NotRequired[str]
lease_owner: str | None lease_owner: str | None
lease_expires_at: str | None lease_expires_at: str | None
last_started_at: str | None last_started_at: str | None
+110 -39
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from ipaddress import ip_address from ipaddress import ip_address
from .html_utils import SafeHtml, attr, escape, trusted 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("_", " ")) 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: def _copy_row(label: str, value: str, sensitivity: str | None = None, *, mask_secret: bool = True) -> SafeHtml:
safe_label = escape(label) parts = _CopyRowParts.from_values(label, value, sensitivity, mask_secret=mask_secret)
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"
return trusted(f""" return trusted(f"""
<div class="{row_class}"> <div class="{parts.row_class}">
<div class="copy-pill"> <div class="copy-pill">
<span class="copy-pill-label">{safe_label}</span> <span class="copy-pill-label">{parts.safe_label}</span>
{value_field} {parts.sensitivity_badge}
{reveal_button} {parts.value_field}
{parts.reveal_button}
</div> </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")} {_svg_icon("clipboard")}<span data-copy-label>Copy</span>{_svg_icon("check", "check-icon")}
</button> </button>
</div> </div>
@@ -66,16 +112,22 @@ def _copy_button(label: str, value: str, *, style: str = "outline-primary") -> S
safe_style = attr(style) safe_style = attr(style)
return trusted( return trusted(
f'<button type="button" class="btn btn-{safe_style} rounded-pill copy-button" data-copy="{safe_value}" ' 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: def _copy_icon_button(label: str, value: str) -> SafeHtml:
safe_label = escape(label) safe_label = escape(label)
safe_value = attr(value) 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( return trusted(
f'<button type="button" class="copy-icon-button" data-copy="{safe_value}" aria-label="Copy {safe_label}" title="Copy">' f"<button {button_attrs}>"
f'{_svg_icon("clipboard")}<span class="visually-hidden" data-copy-label>Copy {safe_label}</span>{_svg_icon("check", "check-icon")}</button>' 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) safe_label = escape(label)
buttons = [] buttons = []
for item_label, item_value in items: 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( buttons.append(
f'<button type="button" class="copy-menu-item" data-copy="{attr(item_value)}" aria-label="Copy {attr(item_label)}">' f"<button {button_attrs}>"
f'{_svg_icon("clipboard")}<span data-copy-label>{escape(item_label)}</span>{_svg_icon("check", "check-icon")}</button>' f'{_svg_icon("clipboard")}<span data-copy-label>{escape(item_label)}</span>'
f'{_svg_icon("check", "check-icon")}</button>'
) )
return trusted(f""" return trusted(f"""
<details class="copy-menu"> <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()) 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) copy_value = copy_value if copy_value is not None else _code_copy_value(commands)
return trusted(f""" 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)} {_copy_icon_button(copy_label, copy_value)}
{code_lines} {code_lines}
</div> </div>
@@ -115,10 +172,14 @@ def _code_window(
def _code_copy_value(commands: str) -> str: def _code_copy_value(commands: str) -> str:
lines = commands.splitlines() 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: if prompt_lines:
return "\n".join(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]: 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: def _manual_update_panel(commands: str) -> SafeHtml:
return trusted(f""" return trusted(f"""
<div class="card card-body gap-3 mt-4"> <div class="card card-body gap-3 mt-4" data-slot="card">
<div> <div data-slot="card-header">
<h3>Manual updates</h3> <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> </div>
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
</div> </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" "Update URL, register URL, and dashboard link" if include_registration else "Update URL and dashboard link"
) )
return trusted(f""" return trusted(f"""
<div class="alert alert-info my-3 py-2 small" role="note"> <div class="alert alert-info ui-alert my-3 py-2 small" role="note" data-slot="alert">
<strong>Public:</strong> Hostname and CNAME target can be shared. <span class="ui-badge ui-badge-public">Public:</span>
<strong>Secret:</strong> {escape(secret_items)} can update or manage this hostname. <span>Hostname and CNAME target can be shared.</span>
Secrets are hidden on screen. Copy still copies the real value. <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> </div>
""") """)
@@ -206,7 +276,8 @@ def _message_band(message: str | None) -> SafeHtml:
if not message: if not message:
return trusted("") return trusted("")
return trusted(f""" 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 class="container">{escape(message)}</div>
</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}") safe_class = attr(f"lucide-icon {css_class}")
return trusted( return trusted(
f'<svg class="{safe_class}" aria-hidden="true" focusable="false" width="16" height="16" viewBox="0 0 24 24" ' 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 from __future__ import annotations
import json import json
from dataclasses import dataclass, field
from .html_utils import SafeHtml, attr, escape, trusted from .html_utils import SafeHtml, attr, escape, trusted
from .icons import _svg_icon 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: def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtml:
account_html = "" 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> <a class="skip-link" href="#main">Skip to content</a>
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation"> <nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
<div class="container"> <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> <a class="navbar-brand brand" href="/">
<div class="navbar-nav site-nav-actions ms-auto flex-row align-items-center justify-content-end gap-2 gap-sm-3"> <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} {links}
{account_html} {account_html}
{theme_toggle} {theme_toggle}
@@ -68,60 +88,11 @@ def _site_footer() -> SafeHtml:
def _page( def _page(
title: str, title: str,
body: SafeHtml, body: SafeHtml,
extra_styles: SafeHtml | None = None, options: PageOptions | None = None,
extra_scripts: SafeHtml | None = None,
description: str = "KarlDNS is a small self-hosted Dynamic DNS service for FRITZ!Box routers, Linux cron jobs, and home networks.",
preload_logo: bool = False,
canonical_url: str | None = None,
image_url: str | None = None,
image_alt: str | None = None,
structured_data: dict[str, object] | None = None,
) -> str: ) -> str:
if not isinstance(body, SafeHtml): if not isinstance(body, SafeHtml):
raise TypeError("body must be a SafeHtml fragment built by a template helper") raise TypeError("body must be a SafeHtml fragment built by a template helper")
extra_styles_html = extra_styles or trusted("") options = options or PageOptions()
extra_scripts_html = extra_scripts or trusted("")
preload_logo_html = (
trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">')
if preload_logo
else trusted("")
)
canonical_html = trusted(f'<link rel="canonical" href="{attr(canonical_url)}">') if canonical_url else trusted("")
social_url = canonical_url or ""
social_image = image_url or ""
social_html = trusted("")
if social_url:
social_card = "summary_large_image" if social_image else "summary"
social_image_alt = image_alt or title
social_image_html = (
f"""
<meta property="og:image" content="{attr(social_image)}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{attr(social_image_alt)}">
<meta name="twitter:image" content="{attr(social_image)}">
<meta name="twitter:image:alt" content="{attr(social_image_alt)}">
"""
if social_image
else ""
)
social_html = trusted(f"""
<meta property="og:type" content="website">
<meta property="og:title" content="{attr(title)}">
<meta property="og:description" content="{attr(description)}">
<meta property="og:url" content="{attr(social_url)}">
{social_image_html}
<meta name="twitter:card" content="{attr(social_card)}">
<meta name="twitter:title" content="{attr(title)}">
<meta name="twitter:description" content="{attr(description)}">
""")
structured_data_html = trusted("")
if structured_data:
structured_data_html = trusted(
'<script type="application/ld+json">'
+ escape(json.dumps(structured_data, ensure_ascii=False, separators=(",", ":")))
+ "</script>"
)
return f""" return f"""
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
@@ -129,25 +100,96 @@ def _page(
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff"> <meta name="theme-color" content="#ffffff">
<meta name="description" content="{attr(description)}"> <meta name="description" content="{attr(options.description)}">
{canonical_html} {_canonical_tag(options.canonical_url)}
{social_html} {_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> <title>{escape(title)}</title>
<link rel="icon" href="/favicon.ico" sizes="any"> <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="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.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="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest"> <link rel="manifest" href="/site.webmanifest">
{preload_logo_html} {_logo_preload_tag(options.preload_logo)}
<script src="/theme.js?v=2"></script> {_base_script_tags()}
<script src="/clipboard.js?v=3" defer></script> {options.extra_scripts}
<script src="/visitor-ip.js?v=1" defer></script> {_base_style_tags()}
{extra_scripts_html} {options.extra_styles}
<link href="/bootstrap-subset.css?v=3" rel="stylesheet"> {_structured_data_tag(options.structured_data)}
<link href="/app.css?v=13" rel="stylesheet">
{extra_styles_html}
{structured_data_html}
</head> </head>
<body>{body}{_site_footer()}</body> <body>{body}{_site_footer()}</body>
</html> </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 from __future__ import annotations
THEME_JS = """ """JavaScript is served from package assets by karldns.routers.assets."""
(() => {
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()
+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 logging
import time import time
from collections.abc import Awaitable, Callable
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from starlette.responses import Response
from starlette.types import ASGIApp, Message, Receive, Scope, Send from starlette.types import ASGIApp, Message, Receive, Scope, Send
from ..security import client_ip, is_rate_limited_path, rate_limit_bucket from ..security import client_ip, is_rate_limited_path, rate_limit_bucket
@@ -13,6 +15,12 @@ from ..settings import DdnsSettings
from ..store import DdnsStore from ..store import DdnsStore
ACCESS_LOGGER = logging.getLogger("karldns.access") ACCESS_LOGGER = logging.getLogger("karldns.access")
SECRET_RESPONSE_PREFIXES = (
"/m/",
"/api/v1/hostnames",
"/api/v1/router/",
"/api/v1/admin/",
)
class RequestBodyTooLargeError(Exception): class RequestBodyTooLargeError(Exception):
@@ -49,7 +57,7 @@ class RequestBodyLimitMiddleware:
def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None: def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None:
@app.middleware("http") @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() started_at = time.perf_counter()
try: try:
content_length = int(request.headers.get("content-length", "0") or "0") 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: def is_secret_response(request: Request) -> bool:
path = request.url.path path = request.url.path
settings = request.app.state.settings return (
if is_admin_path(path, settings) or path.startswith("/m/"): is_admin_path(path, request.app.state.settings)
return True or path.startswith(SECRET_RESPONSE_PREFIXES)
if path.startswith(("/api/v1/hostnames", "/api/v1/router/")): or request.method == "POST"
return True and path == "/"
if path.startswith("/api/v1/admin/"): )
return True
return request.method == "POST" and path == "/"
def is_noindex_response(request: Request) -> bool: 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 __future__ import annotations
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from urllib.parse import urlencode from urllib.parse import urlencode
from ..security import admin_csrf_token from ..security import admin_csrf_token
from ..services.ddns import DdnsService
from ..settings import DdnsSettings from ..settings import DdnsSettings
from ..store import DdnsStore
from .html_utils import SafeHtml, attr, escape, trusted 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 ( from .templates_admin_tables import (
_account_row, _account_row,
_admin_table_pagination, _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: def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
return trusted(f""" return trusted(f"""
<section class="section success-section"> <section class="section success-section">
@@ -33,116 +80,193 @@ def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
def _render_admin_page( def _render_admin_page(
service: DdnsService,
settings: DdnsSettings, settings: DdnsSettings,
accounts: Sequence[Mapping[str, object]], context: AdminPageContext,
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,
) -> str: ) -> str:
csrf = attr(admin_csrf_token(settings)) csrf = attr(admin_csrf_token(settings))
admin_path = settings.admin_path admin_path = settings.admin_path
safe_admin_path = attr(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 """ rows = _account_rows(context.accounts, csrf=csrf, admin_path=admin_path)
<tr class="empty-row"><td colspan="7" class="empty">No hostnames yet.</td></tr> event_rows = _event_rows(context.update_events)
""" accounts_count = escape(context.table_counts.get("accounts", len(context.accounts)))
event_rows = "\n".join(_event_row(event) for event in update_events) or """ event_count = escape(context.table_counts.get("update_events", len(context.update_events)))
<tr class="empty-row"><td colspan="7" class="empty">No update events yet.</td></tr> metrics_html = _metrics_panel(context.service_metrics)
"""
accounts_count = escape(table_counts.get("accounts", len(accounts)))
event_count = escape(table_counts.get("update_events", len(update_events)))
metrics_html = _metrics_panel(service_metrics)
account_pager = _server_page_links( 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( event_pager = _server_page_links(
admin_path, admin_path,
"update_events_offset", "update_events_offset",
table_offsets.get("update_events", 0), context.table_offsets.get("update_events", 0),
table_limit, context.table_limit,
table_counts.get("update_events", 0), context.table_counts.get("update_events", 0),
) )
cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf) cleanup_panel = _cleanup_panel(settings, context.cleanup_runs, context.scheduled_tasks, safe_admin_path, csrf)
totals_html = _totals_strip(table_counts, service_metrics) 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( return _page(
"KarlDNS Admin", "KarlDNS Admin",
trusted(f""" trusted(f"""
<main id="main"> <main id="main">
{_top_nav("Operator")} {_top_nav("Operator")}
<section class="admin-console"> {title_section}
<div class="container"> {context.notice_html or ""}
<div class="admin-page-title"> {status_section}
<div> {hostnames_section}
<p class="eyebrow">KarlDNS</p> {events_section}
<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>
</main> </main>
"""), """),
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'), PageOptions(
extra_scripts=trusted('<script src="/admin-tables.js?v=2" defer></script>'), 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: def _totals_strip(table_counts: dict[str, int], metrics: dict[str, float]) -> SafeHtml:
items = [ items = [
("Hostnames", table_counts.get("accounts", 0)), ("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" alert_class = "alert-success" if kind == "success" else "alert-info"
return trusted(f""" return trusted(f"""
<div class="container"> <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> </div>
""") """)
@@ -180,8 +304,10 @@ def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: in
return trusted(f""" return trusted(f"""
<nav class="d-flex align-items-center justify-content-end gap-2 mt-2" aria-label="Server-side table pages"> <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> <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{previous_disabled}" href="{previous_href}"
<a class="btn btn-sm btn-light rounded-pill{next_disabled}" href="{next_href}" aria-disabled="{str(next_offset >= total).lower()}">Next</a> 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> </nav>
""") """)
@@ -193,37 +319,31 @@ def _cleanup_panel(
safe_admin_path: str, safe_admin_path: str,
csrf: str, csrf: str,
) -> SafeHtml: ) -> SafeHtml:
task = next( summary = _cleanup_summary(
(item for item in scheduled_tasks if item.get("name") == "cleanup"), cleanup_runs=cleanup_runs,
scheduled_tasks[0] if scheduled_tasks else {}, 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 = ( error_html = (
trusted(f'<p class="small text-danger mb-0">Last error: {escape(last_error)}</p>') trusted(f'<p class="small text-danger mb-0">Last error: {escape(summary.last_error)}</p>')
if last_error if summary.last_error
else trusted("") else trusted("")
) )
last_run_title = attr(str(last_run or "Never")) last_run_title = attr(str(summary.last_run or "Never"))
next_run_title = attr(str(next_run or "Pending")) next_run_title = attr(str(summary.next_run or "Pending"))
last_run_display = escape(_format_timestamp(last_run, "Never")) last_run_display = escape(_format_timestamp(summary.last_run, "Never"))
next_run_display = escape(_format_timestamp(next_run, "Pending")) next_run_display = escape(_format_timestamp(summary.next_run, "Pending"))
return trusted(f""" return trusted(f"""
<div class="admin-cleanup-bar"> <div class="admin-cleanup-bar">
<div class="admin-cleanup-title"> <div class="admin-cleanup-title">
<h2>Cleanup</h2> <h2>Cleanup</h2>
<span>Every {escape(_format_interval(interval))}</span> <span>Every {escape(_format_interval(summary.interval_seconds))}</span>
</div> </div>
<dl class="admin-cleanup-times"> <dl class="admin-cleanup-times">
<div><dt>Last run</dt><dd title="{last_run_title}">{last_run_display}</dd></div> <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>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>Status</dt><dd>{escape(str(summary.status))}</dd></div>
<div><dt>Removed</dt><dd>{escape(accounts_removed)}</dd></div> <div><dt>Removed</dt><dd>{escape(summary.accounts_removed)}</dd></div>
</dl> </dl>
{error_html} {error_html}
<form method="post" action="{safe_admin_path}/cleanup" aria-label="Run cleanup now"> <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: def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
samples = metrics.get("update_latency_samples_total", 0) samples = metrics.get("update_latency_samples_total", 0)
average_latency = metrics.get("update_latency_ms_total", 0) / samples if samples else 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"), ("Last update time", f"{metrics.get('last_update_latency_ms', 0):.1f} ms"),
( (
"Cleanup ok/errors", "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""" cards = "\n".join(f"""
+51 -18
View File
@@ -1,12 +1,49 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from .components import _event_label from .components import _event_label
from .html_utils import attr, escape 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: def _account_row(account: Mapping[str, object], csrf: str = "", admin_path: str = "/admin") -> str:
raw_hostname = str(account["hostname"]) raw_hostname = str(account["hostname"])
hostname = escape(raw_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"> <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="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname_attr}"> <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="update" class="btn btn-sm btn-light rounded-pill"
<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> 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> </form>
</td> </td>
<td class="cell-delete"> <td class="cell-delete">
<form method="post" action="{safe_admin_path}/hostnames/delete"> <form method="post" action="{safe_admin_path}/hostnames/delete">
<input type="hidden" name="csrf" value="{attr(csrf)}"> <input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname_attr}"> <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> </form>
</td> </td>
</tr> </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"> <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"> <label class="w-100 mb-0" for="{safe_id}-search">
<span class="visually-hidden">{safe_label}</span> <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> </label>
<div class="d-flex align-items-center gap-2 flex-shrink-0"> <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> <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: def _event_row(event: Mapping[str, object]) -> str:
created_raw = str(event.get("created_at") or "-") row = _admin_event_row_view(event)
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)
return ( return (
f'<tr><td class="cell-time" title="{attr(created_raw)}">{created}</td>' f'<tr><td class="cell-time" title="{attr(row.created_raw)}">{row.created}</td>'
f'<td class="cell-hostname" title="{attr(hostname_raw)}">{hostname}</td>' f'<td class="cell-hostname" title="{attr(row.hostname_raw)}">{row.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-status">{row.status}</td><td class="cell-ip">{row.ipv4}</td>'
f'<td class="cell-count">{count}</td><td class="cell-detail" title="{attr(detail_raw)}">{detail}</td></tr>' 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 __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING
from ..services.ddns import DdnsService from ..services.ddns import DdnsService
from ..settings import DdnsSettings
from .components import ( from .components import (
_code_window, _code_window,
_copy_button, _copy_button,
@@ -14,229 +15,383 @@ from .components import (
_secret_notice, _secret_notice,
) )
from .html_utils import SafeHtml, attr, escape, trusted 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( @dataclass(frozen=True)
service: DdnsService, class ManagementPageContext:
settings: DdnsSettings, management_slug: str
management_slug: str, account: Mapping[str, object]
account: Mapping[str, object], csrf_token: str
csrf_token: str, message: str | None = None
message: str | None = None, request_ip: str | None = None
request_ip: str | None = None,
) -> str: @property
account_for_url = { def account_for_url(self) -> dict[str, str]:
"hostname": str(account["hostname"]), return {
"update_slug": str(account["update_slug"]), "hostname": str(self.account["hostname"]),
"management_slug": management_slug, "update_slug": str(self.account["update_slug"]),
} "management_slug": self.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 "-") @dataclass(frozen=True, slots=True)
updated = escape(account.get("updated_at") or "Never") class _ManagementPageView:
fritzbox_fields = _fritzbox_fields(update_url, hostname) update_url: str
cname_record = f"your-subdomain.example.com. CNAME {hostname}." hostname: str
manual_commands = _manual_update_commands(update_url, request_ip) ipv4: str
history_rows = ( ipv6: str
"\n".join( updated: str
_user_event_row(event) fritzbox_fields: str
for event in service.store.list_update_events_for_hostname(str(account["hostname"]), 10) cname_record: str
) manual_commands: str
or """
<tr><td colspan="6" class="empty">No router updates yet.</td></tr>
""" 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( 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( return _page(
"KarlDNS management", "KarlDNS management",
trusted(f""" trusted(f"""
<main id="main" class="dashboard-page"> <main id="main" class="dashboard-page">
{_top_nav()} {_top_nav()}
<section class="dashboard-shell"> {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_password_panel} {dashboard_password_panel}
<section class="dashboard-section"> {history_section}
<div class="container"> {delete_section}
<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>
</main> </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( def _management_history_rows(service: DdnsService, context: ManagementPageContext) -> SafeHtml:
service: DdnsService, events = service.store.list_update_events_for_hostname(str(context.account["hostname"]), 10)
settings: DdnsSettings, rows = "\n".join(_user_event_row(event) for event in events)
management_slug: str, return trusted(rows or """
account: Mapping[str, object], <tr class="empty-row">
csrf_token: str, <td colspan="6" class="empty">
message: str | None = None, <div class="empty-state">
request_ip: str | None = None, <div class="empty-state-title">No router updates yet.</div>
) -> str: <div class="empty-state-description">
raw_hostname = str(account["hostname"]) This table fills after the router or a script sends its first address update.
account_for_url = { </div>
"hostname": raw_hostname, </div>
"update_slug": str(account["update_slug"]), </td>
"management_slug": management_slug, </tr>
} """)
update_url = service.fritz_update_url(account_for_url)
dashboard_url = service.dashboard_url({"management_slug": management_slug})
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) hostname = escape(raw_hostname)
message_html = _management_message(message) message_html = _management_message(context.message)
cleanup_hours = settings.cleanup_pending_account_hours cleanup_hours = context.settings.cleanup_pending_account_hours
cleanup_text = f"{cleanup_hours} hours" if cleanup_hours != 24 else "24 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( return _page(
"KarlDNS setup", "KarlDNS setup",
trusted(f""" trusted(f"""
<main id="main"> <main id="main">
{_top_nav()} {_top_nav()}
<section class="hero-band compact"> {hero}
<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>
{message_html} {message_html}
<section class="section"> {values_section}
<div class="container narrow d-grid gap-4"> {router_section}
<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>
</main> </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: def _management_message(message: str | None) -> SafeHtml:
if not message: if not message:
return trusted("") return trusted("")
return trusted(f""" 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 class="container">{escape(message)}</div>
</div> </div>
""") """)
@@ -248,10 +403,12 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
remove_form = "" remove_form = ""
if password_enabled: if password_enabled:
remove_form = f""" 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)}"> <input type="hidden" name="csrf" value="{attr(csrf_token)}">
<label class="form-label" for="dashboard-remove-current-password">Current password</label> <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> <button class="btn btn-outline-danger rounded-pill" type="submit">Remove password</button>
</form> </form>
""" """
@@ -260,7 +417,8 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
current_password_field = """ current_password_field = """
<div> <div>
<label class="form-label" for="dashboard-current-password">Current password</label> <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> </div>
""" """
return trusted(f""" return trusted(f"""
@@ -270,19 +428,26 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
<div> <div>
<p class="eyebrow">Dashboard password</p> <p class="eyebrow">Dashboard password</p>
<h2>Password protection</h2> <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>
<div class="d-grid gap-3"> <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)}"> <input type="hidden" name="csrf" value="{attr(csrf_token)}">
{current_password_field} {current_password_field}
<div> <div>
<label class="form-label" for="dashboard-password">{escape(password_action)}</label> <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>
<div> <div>
<label class="form-label" for="dashboard-password-confirm">Confirm password</label> <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>
<div class="d-flex gap-2 flex-wrap"> <div class="d-flex gap-2 flex-wrap">
<button class="btn btn-primary rounded-pill" type="submit">{escape(password_action)}</button> <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"]) hostname = escape(account["hostname"])
message_html = "" message_html = ""
if message: 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( return _page(
"Unlock KarlDNS management", "Unlock KarlDNS management",
trusted(f""" trusted(f"""
@@ -321,11 +488,13 @@ def _render_management_unlock_page(
<section class="section"> <section class="section">
<div class="container narrow"> <div class="container narrow">
{message_html} {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)}"> <input type="hidden" name="csrf" value="{attr(csrf_token)}">
<div> <div>
<label class="form-label" for="dashboard-unlock-password">Dashboard password</label> <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> </div>
<label class="form-check d-flex align-items-center gap-2"> <label class="form-check d-flex align-items-center gap-2">
<input class="form-check-input" type="checkbox" name="stay_unlocked" value="1"> <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: 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 "") raw_status = str(event.get("status") or "")
status = escape(_event_label(raw_status)) return _UserEventRow(
badge_class = attr(_event_badge_class(raw_status)) created=escape(_event_text(event, "created_at")),
ipv4 = escape(event.get("ipv4") or "-") status=escape(_event_label(raw_status)),
ipv6 = escape(event.get("ipv6") or "-") badge_class=_event_badge_class(raw_status),
count = escape(event.get("count") or 1) ipv4=escape(_event_text(event, "ipv4")),
detail = escape(event.get("detail") or "-") ipv6=escape(_event_text(event, "ipv6")),
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>' 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: def _event_badge_class(status: str) -> str:
+223 -145
View File
@@ -1,12 +1,25 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from ..settings import DdnsSettings from ..settings import DdnsSettings
from .components import _code_window, _example_update_ips, _message_band from .components import _code_window, _example_update_ips, _message_band
from .html_utils import escape, trusted from .html_utils import trusted
from .layout import _page, _top_nav 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 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( def _render_public_home(
settings: DdnsSettings, settings: DdnsSettings,
message: str | None, message: str | None,
@@ -14,8 +27,7 @@ def _render_public_home(
) -> str: ) -> str:
base_url = settings.public_base_url.rstrip("/") base_url = settings.public_base_url.rstrip("/")
hostname_suffix = settings.hostname_suffix or "your KarlDNS suffix" hostname_suffix = settings.hostname_suffix or "your KarlDNS suffix"
suffix = escape(hostname_suffix) demo_update_url = f"{base_url}/api/v1/router/update/<secret>?myip=<ipaddr>"
demo_update_url = escape(f"{base_url}/api/v1/router/update/<secret>?myip=<ipaddr>")
example_ipv4, _ = _example_update_ips(request_ip) 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_command = f"curl -sS '{base_url}/api/v1/router/update/<secret>?myip={example_ipv4}'"
create_demo = f"{create_demo_command}\ngood {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 "" 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( return _page(
"KarlDNS", "KarlDNS",
trusted(f""" trusted(f"""
<main id="main"> <main id="main">
{_top_nav()} {_top_nav()}
<section class="hero-band"> {_public_home_hero(suffix=hostname_suffix, demo_update_url=demo_update_url)}
<div class="container text-center d-grid justify-items-center gap-3">
<img class="hero-logo" src="/logo.webp?v=3" alt="" width="80" height="80" decoding="async" fetchpriority="high">
<h1>Dynamic DNS for home routers.</h1>
<div class="install-pill mx-auto">
<code>{demo_update_url}</code>
</div>
<p class="lead">KarlDNS gives you a generated DynDNS hostname under {suffix}. Add the private update URL to your router, or use it from a script.</p>
<div class="d-flex gap-2 flex-wrap justify-content-center mt-2">
<form method="post" action="/" class="hero-action-form" aria-label="Create a generated DynDNS hostname">
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
</form>
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">Manual updates</a>
</div>
</div>
</section>
{message_html} {message_html}
<section class="section section-compact"> {_setup_flow_section(create_demo=create_demo, create_demo_command=create_demo_command)}
<div class="container narrow text-center"> {_manual_updates_section(manual_demo=manual_demo)}
<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>
{_linux_installer_section(base_url)} {_linux_installer_section(base_url)}
</main> </main>
"""), """),
description=description, PageOptions(
preload_logo=True, description=description,
canonical_url=f"{base_url}/", preload_logo=True,
image_url=f"{base_url}/social-preview.png", canonical_url=f"{base_url}/",
image_alt="KarlDNS landing page with private Dynamic DNS update URL", image_url=f"{base_url}/social-preview.png",
structured_data={ image_alt="KarlDNS landing page with private Dynamic DNS update URL",
"@context": "https://schema.org", structured_data={
"@type": "SoftwareApplication", "@context": "https://schema.org",
"name": "KarlDNS", "@type": "SoftwareApplication",
"applicationCategory": "NetworkingApplication", "name": "KarlDNS",
"operatingSystem": "Linux", "applicationCategory": "NetworkingApplication",
"url": f"{base_url}/", "operatingSystem": "Linux",
"description": description, "url": f"{base_url}/",
"image": f"{base_url}/social-preview.png", "description": description,
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "EUR"}, "image": f"{base_url}/social-preview.png",
"featureList": [ "offers": {"@type": "Offer", "price": "0", "priceCurrency": "EUR"},
"Dynamic DNS for FRITZ!Box routers", "featureList": [
"Accountless private update URLs", "Dynamic DNS for FRITZ!Box routers",
"CNAME target for external DNS names", "Accountless private update URLs",
"Linux cron updater", "CNAME target for external DNS names",
], "Linux cron updater",
}, ],
},
),
) )
def _render_privacy_page(settings: DdnsSettings) -> str: def _render_privacy_page(settings: DdnsSettings) -> str:
base_url = settings.public_base_url.rstrip("/") 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( return _page(
"Legal and Privacy", "Legal and Privacy",
trusted(f""" trusted(f"""
@@ -119,113 +105,205 @@ def _render_privacy_page(settings: DdnsSettings) -> str:
<div class="container narrow d-grid gap-4"> <div class="container narrow d-grid gap-4">
<div> <div>
<h2>Data processed</h2> <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>
<div> <div>
<h2>Secrets</h2> <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>
<div> <div>
<h2>Logs and retention</h2> <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>
<div> <div>
<h2>Contact</h2> <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>
</div> </div>
</section> </section>
</main> </main>
"""), """),
description=description, PageOptions(
canonical_url=f"{base_url}/privacy", description=description,
image_url=f"{base_url}/social-preview.png", canonical_url=f"{base_url}/privacy",
image_alt="KarlDNS legal and privacy information", image_url=f"{base_url}/social-preview.png",
structured_data={ image_alt="KarlDNS legal and privacy information",
"@context": "https://schema.org", structured_data={
"@type": "WebPage", "@context": "https://schema.org",
"name": "KarlDNS Legal and Privacy", "@type": "WebPage",
"url": f"{base_url}/privacy", "name": "KarlDNS Legal and Privacy",
"description": description, "url": f"{base_url}/privacy",
"isPartOf": { "description": description,
"@type": "WebSite", "isPartOf": {
"name": "KarlDNS", "@type": "WebSite",
"url": f"{base_url}/", "name": "KarlDNS",
"url": f"{base_url}/",
},
}, },
}, ),
) )
def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None) -> str: def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None) -> str:
base_url = settings.public_base_url.rstrip("/") base_url = settings.public_base_url.rstrip("/")
example_ipv4, _ = _example_update_ips(request_ip) example_ipv4, _ = _example_update_ips(request_ip)
create_example = f"curl -sS -X POST {base_url}/api/v1/hostnames" examples = _api_docs_examples(base_url, example_ipv4)
register_example = ( description = (
f"curl -sS -X POST '{base_url}/api/v1/router/register/<karl_dashboard_secret>?myip={example_ipv4}'" "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( return _page(
"KarlDNS API", "KarlDNS API",
trusted(f""" trusted(f"""
<main id="main"> <main id="main">
{_top_nav()} {_top_nav()}
<section class="hero-band compact"> {_api_docs_hero()}
<div class="container text-center d-grid justify-items-center gap-3"> {_api_docs_content(examples)}
<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>
</main> </main>
"""), """),
description=description, PageOptions(
canonical_url=f"{base_url}/docs", description=description,
image_url=f"{base_url}/social-preview.png", canonical_url=f"{base_url}/docs",
image_alt="KarlDNS API documentation", 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"> <div class="text-center mb-4">
<p class="eyebrow">Linux cron updater</p> <p class="eyebrow">Linux cron updater</p>
<h2>Linux updater.</h2> <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>
<div class="installer-commands"> <div class="installer-commands">
<div class="installer-command"> <div class="installer-command">
+6 -2
View File
@@ -55,8 +55,12 @@ line-length = 120
target-version = "py311" target-version = "py311"
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"] select = ["E", "F", "I", "UP", "B", "ANN"]
ignore = ["E501"] ignore = ["ANN401"]
[tool.ruff.lint.per-file-ignores]
"scripts/build-bootstrap-subset.py" = ["E501"]
"tests/**/*.py" = ["ANN"]
[tool.black] [tool.black]
line-length = 120 line-length = 120
+4 -1
View File
@@ -16,7 +16,7 @@ def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) ->
assert response.status_code == 200 assert response.status_code == 200
assert response.headers["cache-control"] == "no-store" assert response.headers["cache-control"] == "no-store"
assert '<link href="/admin.css?v=5" rel="stylesheet">' in response.text assert '<link href="/admin.css?v=5" rel="stylesheet">' in response.text
assert '<script src="/admin-tables.js?v=2" defer></script>' in response.text assert '<script src="/admin-tables.js?v=3" defer></script>' in response.text
assert "Operator console" in response.text assert "Operator console" in response.text
assert "Generate credentials" not in response.text assert "Generate credentials" not in response.text
assert 'action="/admin/accounts"' not in response.text assert 'action="/admin/accounts"' not in response.text
@@ -24,6 +24,9 @@ def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) ->
assert response.text.count("data-table-search") == 2 assert response.text.count("data-table-search") == 2
assert response.text.count("data-table-page-size") == 2 assert response.text.count("data-table-page-size") == 2
assert response.text.count("data-table-pagination") == 2 assert response.text.count("data-table-pagination") == 2
assert 'placeholder="Filter visible hostnames"' in response.text
assert 'placeholder="Filter visible update events"' in response.text
assert 'placeholder="Search hostnames"' not in response.text
assert "admin-table-scroll" in response.text assert "admin-table-scroll" in response.text
assert "Cleanup" in response.text assert "Cleanup" in response.text
assert "Run now" in response.text assert "Run now" in response.text
+90
View File
@@ -0,0 +1,90 @@
from pathlib import Path
from karldns import DdnsStore
from karldns.routers.admin import _admin_cleanup_notice, _admin_table_offsets
from karldns.web.templates_admin import (
_account_rows,
_admin_page_context,
_cleanup_accounts_removed,
_cleanup_last_run,
_cleanup_summary,
_cleanup_task,
_event_rows,
)
def test_admin_page_context_collects_counts_and_offset_rows(tmp_path: Path) -> None:
store = DdnsStore(tmp_path / "ddns.sqlite3")
first = store.create_account("first.example.net", "router")
second = store.create_account("second.example.net", "router")
store.log_update_event(first["hostname"], None, None, "first", "first detail", "198.51.100.1")
store.log_update_event(second["hostname"], None, None, "second", "second detail", "198.51.100.2")
context = _admin_page_context(
store,
table_offsets={"accounts": 1, "update_events": 1},
table_limit=1,
notice_html=None,
)
assert context.accounts[0]["hostname"] == "second.example.net"
assert context.update_events[0]["hostname"] == "first.example.net"
assert context.table_counts["accounts"] == 2
assert context.table_counts["update_events"] == 2
assert context.table_limit == 1
def test_admin_rows_render_empty_states_when_tables_have_no_rows() -> None:
assert "No hostnames yet." in _account_rows([], csrf="csrf", admin_path="/admin")
assert "No update events yet." in _event_rows([])
def test_admin_table_offsets_groups_query_values() -> None:
assert _admin_table_offsets(accounts_offset=1, cleanup_runs_offset=2, update_events_offset=3) == {
"accounts": 1,
"cleanup_runs": 2,
"update_events": 3,
}
def test_admin_cleanup_notice_is_empty_without_done_flag() -> None:
assert _admin_cleanup_notice(cleanup_done=False, cleanup_unused=1, cleanup_events=2) is None
assert "Removed 1 hostnames and 2 old events" in _admin_cleanup_notice(
cleanup_done=True,
cleanup_unused=1,
cleanup_events=2,
)
def test_cleanup_task_prefers_named_cleanup_task() -> None:
task = _cleanup_task(
[
{"name": "other", "next_run_at": "wrong"},
{"name": "cleanup", "next_run_at": "right"},
]
)
assert task["next_run_at"] == "right"
def test_cleanup_summary_uses_task_and_last_run_fallback() -> None:
summary = _cleanup_summary(
cleanup_runs=[{"created_at": "2026-01-02T03:04:05+00:00", "unused_accounts_deleted": 3}],
scheduled_tasks=[{"name": "cleanup", "interval_seconds": 3600}],
fallback_interval_seconds=7200,
)
assert summary.last_run == "2026-01-02T03:04:05+00:00"
assert summary.next_run is None
assert summary.status == "pending"
assert summary.interval_seconds == 3600
assert summary.accounts_removed == 3
def test_cleanup_summary_helpers_prefer_task_and_default_missing_cleanup_values() -> None:
task = {"last_finished_at": "2026-01-03T00:00:00+00:00"}
cleanup = {"created_at": "2026-01-02T00:00:00+00:00"}
assert _cleanup_last_run(task, cleanup) == "2026-01-03T00:00:00+00:00"
assert _cleanup_last_run({}, cleanup) == "2026-01-02T00:00:00+00:00"
assert _cleanup_accounts_removed({}) == 0
+10
View File
@@ -0,0 +1,10 @@
from karldns.web.templates_admin_tables import _admin_event_row_view
def test_admin_event_row_normalizes_missing_values() -> None:
row = _admin_event_row_view({"created_at": "", "status": "updated"})
assert row.created_raw == "-"
assert row.hostname_raw == "-"
assert row.detail_raw == "-"
assert row.count == "1"
+38
View File
@@ -0,0 +1,38 @@
from karldns.web.components import _code_copy_line, _code_copy_value, _copy_row, _CopyRowParts
def test_copy_row_parts_derive_secret_state_and_badge() -> None:
parts = _CopyRowParts.from_values("Update-URL:", "https://example.test/update", "Secret", mask_secret=True)
assert parts.is_secret is True
assert parts.should_mask is True
assert parts.row_class == "copy-field copy-field-secret"
assert "ui-badge-secret" in parts.sensitivity_badge
def test_copy_row_masks_secret_values_by_default() -> None:
html = _copy_row("Update-URL:", "https://example.test/update", "Secret")
assert 'class="copy-field copy-field-secret"' in html
assert 'type="password"' in html
assert "data-secret-toggle" in html
assert 'data-copy="https://example.test/update"' in html
def test_copy_row_can_render_unmasked_secret_values() -> None:
html = _copy_row("Update-URL:", "https://example.test/update", "Secret", mask_secret=False)
assert 'class="copy-field copy-field-secret"' in html
assert 'type="password"' not in html
assert '<code class="copy-pill-value copy-value">https://example.test/update</code>' in html
def test_code_copy_value_prefers_prompt_lines_when_present() -> None:
commands = "intro\n$ first command\noutput\n$ second command"
assert _code_copy_value(commands) == "first command\nsecond command"
def test_code_copy_line_strips_shell_prompt() -> None:
assert _code_copy_line("$ curl example") == "curl example"
assert _code_copy_line("plain output") == "plain output"
+57
View File
@@ -0,0 +1,57 @@
import pytest
from karldns.services.ddns import (
INITIAL_PLACEHOLDER_IPV4,
BadIpError,
UpdateIpParams,
_initial_dns_plan,
_required_ip_param,
)
def test_update_ip_params_expose_initial_plan_arguments() -> None:
params = UpdateIpParams(myip="203.0.113.9", ipaddr=None, myipv6="2001:db8::9", ip6addr=None)
assert params.initial_plan_args == ("203.0.113.9", None, "2001:db8::9", None)
def test_required_ip_param_rejects_invalid_or_wrong_version_values() -> None:
assert _required_ip_param("myip", "203.0.113.77", version=4) == "203.0.113.77"
with pytest.raises(BadIpError, match="valid IPv4"):
_required_ip_param("myip", "not-an-ip", version=4)
with pytest.raises(BadIpError, match="IPv4"):
_required_ip_param("myip", "2001:db8::1", version=4)
def test_initial_dns_plan_uses_placeholder_without_supplied_or_public_source_ip() -> None:
plan = _initial_dns_plan(
source_ip="172.18.0.5",
myip=None,
ipaddr=None,
myipv6=None,
ip6addr=None,
)
assert plan.ipv4 == INITIAL_PLACEHOLDER_IPV4
assert plan.ipv6 is None
assert plan.update_ipv4 is True
assert plan.update_ipv6 is False
assert plan.detail == "initial placeholder dns registered"
def test_initial_dns_plan_uses_supplied_ip_values() -> None:
plan = _initial_dns_plan(
source_ip="172.18.0.5",
myip="203.0.113.77",
ipaddr=None,
myipv6=None,
ip6addr=None,
)
assert plan.ipv4 == "203.0.113.77"
assert plan.ipv6 is None
assert plan.update_ipv4 is True
assert plan.update_ipv6 is False
assert plan.detail == "initial dns registered"
+14
View File
@@ -0,0 +1,14 @@
import base64
from karldns.dependencies import _basic_auth_password
def _basic_header(username: str, password: str) -> str:
token = base64.b64encode(f"{username}:{password}".encode()).decode()
return f"Basic {token}"
def test_basic_auth_password_extracts_only_basic_passwords() -> None:
assert _basic_auth_password(_basic_header("admin", "secret")) == "secret"
assert _basic_auth_password("Bearer token") is None
assert _basic_auth_password(None) is None
+46
View File
@@ -0,0 +1,46 @@
from karldns.web.html_utils import trusted
from karldns.web.layout import PageOptions, _page, _social_meta_tags, _structured_data_tag
def test_page_options_render_metadata_and_preloads() -> None:
html = _page(
"KarlDNS",
trusted("<main id='main'></main>"),
PageOptions(
description="Dynamic DNS",
preload_logo=True,
canonical_url="https://karldns.example/",
image_url="https://karldns.example/social.png",
image_alt="Preview",
structured_data={"name": "KarlDNS"},
),
)
assert 'content="Dynamic DNS"' in html
assert 'rel="canonical" href="https://karldns.example/"' in html
assert 'rel="preload" as="image" href="/logo.webp?v=3"' in html
assert 'property="og:image" content="https://karldns.example/social.png"' in html
assert 'type="application/ld+json"' in html
def test_social_meta_tags_include_large_card_when_image_exists() -> None:
html = _social_meta_tags(
title="KarlDNS",
description="Dynamic DNS",
canonical_url="https://karldns.de/",
image_url="https://karldns.de/social-preview.png",
image_alt="Preview",
)
assert 'property="og:title" content="KarlDNS"' in html
assert 'property="og:image" content="https://karldns.de/social-preview.png"' in html
assert 'name="twitter:card" content="summary_large_image"' in html
assert 'name="twitter:image:alt" content="Preview"' in html
def test_structured_data_tag_escapes_json_for_html() -> None:
html = _structured_data_tag({"name": "KarlDNS", "description": "<dynamic>"})
assert 'type="application/ld+json"' in html
assert "&lt;dynamic&gt;" in html
assert "<dynamic>" not in html
+48
View File
@@ -0,0 +1,48 @@
import asyncio
from starlette.responses import HTMLResponse
from karldns.routers.management_passwords import (
_dashboard_password_error,
_dashboard_password_error_response,
_DashboardPasswordErrorContext,
)
def test_dashboard_password_error_requires_minimum_length() -> None:
assert _dashboard_password_error("short", "short") == "Password must be at least 8 characters."
def test_dashboard_password_error_requires_confirmation_match() -> None:
assert _dashboard_password_error("correct horse", "different horse") == "The passwords do not match."
def test_dashboard_password_error_accepts_valid_password() -> None:
assert _dashboard_password_error("correct horse", "correct horse") is None
def test_dashboard_password_error_response_renders_new_password_errors(monkeypatch) -> None:
async def fake_render_management_page_async(_service: object, context: object) -> str:
return f"message={context.message}"
monkeypatch.setattr(
"karldns.routers.management_passwords.render_management_page_async",
fake_render_management_page_async,
)
response = asyncio.run(
_dashboard_password_error_response(
object(),
_DashboardPasswordErrorContext(
management_slug="manage-secret",
account={"hostname": "home.example.net"},
form={"dashboard_password": ["short"], "dashboard_password_confirm": ["short"]},
request_ip="198.51.100.1",
current_password_error=None,
),
)
)
assert isinstance(response, HTMLResponse)
assert response.status_code == 400
assert b"Password must be at least 8 characters." in response.body
+46
View File
@@ -0,0 +1,46 @@
from karldns.routers.management import _activation_ip_params_from_query
from karldns.services.ddns import UpdateIpParams
from karldns.web.templates_management import ManagementPageContext, _management_page_view, _user_event_row_view
def test_management_page_context_builds_account_for_secret_urls() -> None:
context = ManagementPageContext(
management_slug="manage-secret",
account={"hostname": "home.example.net", "update_slug": "update-secret"},
csrf_token="csrf",
)
assert context.account_for_url == {
"hostname": "home.example.net",
"update_slug": "update-secret",
"management_slug": "manage-secret",
}
def test_management_page_view_escapes_missing_status_values() -> None:
context = ManagementPageContext(
management_slug="manage-secret",
account={"hostname": "home.example.net", "update_slug": "update-secret"},
csrf_token="csrf",
)
view = _management_page_view(update_url="https://example.test/update", context=context)
assert view.hostname == "home.example.net"
assert view.ipv4 == "-"
assert view.ipv6 == "-"
assert view.updated == "Never"
def test_activation_ip_params_from_query_builds_domain_params() -> None:
assert _activation_ip_params_from_query(myip=None, ipaddr="203.0.113.8", myipv6=None, ip6addr=None) == (
UpdateIpParams(ipaddr="203.0.113.8")
)
def test_user_event_row_normalizes_status_badge() -> None:
row = _user_event_row_view({"status": "dns_failed", "detail": ""})
assert row.status == "DNS update failed"
assert row.badge_class == "status-badge-error"
assert row.detail == "-"
+25
View File
@@ -0,0 +1,25 @@
from karldns.dns.rfc2136 import _apply_record_update, _RecordUpdate
class _FakeUpdate:
def __init__(self) -> None:
self.calls: list[tuple[object, ...]] = []
def replace(self, *args: object) -> None:
self.calls.append(("replace", *args))
def delete(self, *args: object) -> None:
self.calls.append(("delete", *args))
def test_apply_record_update_replaces_deletes_or_skips_record_type() -> None:
update = _FakeUpdate()
_apply_record_update(update, "home", _RecordUpdate(ttl=60, record_type="A", value="203.0.113.7", enabled=True))
_apply_record_update(update, "home", _RecordUpdate(ttl=60, record_type="AAAA", value=None, enabled=True))
_apply_record_update(update, "home", _RecordUpdate(ttl=60, record_type="TXT", value="ignored", enabled=False))
assert update.calls == [
("replace", "home", 60, "A", "203.0.113.7"),
("delete", "home", "AAAA"),
]
+5 -4
View File
@@ -2,7 +2,6 @@ import asyncio
import logging import logging
import sqlite3 import sqlite3
import time import time
from contextlib import contextmanager
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
@@ -324,10 +323,8 @@ def test_rate_limit_flush_requeues_counts_after_database_failure(
original_connect = store.connect original_connect = store.connect
@contextmanager
def fail_connect(): def fail_connect():
raise RuntimeError("database temporarily unavailable") raise RuntimeError("database temporarily unavailable")
yield
monkeypatch.setattr(store, "connect", fail_connect) monkeypatch.setattr(store, "connect", fail_connect)
with pytest.raises(RuntimeError, match="database temporarily unavailable"): with pytest.raises(RuntimeError, match="database temporarily unavailable"):
@@ -416,7 +413,11 @@ def test_scheduled_cleanup_failure_is_logged(tmp_path: Path, monkeypatch, caplog
def test_scheduler_loop_survives_iteration_exception(tmp_path: Path, monkeypatch, caplog) -> None: def test_scheduler_loop_survives_iteration_exception(tmp_path: Path, monkeypatch, caplog) -> None:
settings = DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin", cleanup_interval_seconds=60) settings = DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
cleanup_interval_seconds=60,
)
store = DdnsStore(settings.database_path) store = DdnsStore(settings.database_path)
calls = 0 calls = 0
+22
View File
@@ -0,0 +1,22 @@
from karldns.schemas import _account_status, _row_text
def test_row_text_returns_none_for_missing_or_empty_values():
row = {
"empty": "",
"missing": None,
"number": 42,
"text": "hello",
}
assert _row_text(row, "empty") is None
assert _row_text(row, "missing") is None
assert _row_text(row, "unknown") is None
assert _row_text(row, "number") == "42"
assert _row_text(row, "text") == "hello"
def test_account_status_prefers_disabled_before_lifecycle_state():
assert _account_status({"disabled": True, "accepted_at": "2026-01-01"}) == "disabled"
assert _account_status({"accepted_at": "2026-01-01"}) == "active"
assert _account_status({}) == "pending"
+25 -2
View File
@@ -395,6 +395,15 @@ def test_invalid_dns_provider_is_rejected() -> None:
validate_dns_provider_settings(settings) validate_dns_provider_settings(settings)
def test_dns_backend_enabled_recognizes_explicit_or_legacy_rfc2136() -> None:
from karldns.dns.base import _dns_backend_enabled
assert _dns_backend_enabled("cloudflare", DdnsSettings()) is True
assert _dns_backend_enabled("rfc2136", DdnsSettings()) is True
assert _dns_backend_enabled("", DdnsSettings(rfc2136_server="192.0.2.53")) is True
assert _dns_backend_enabled("", DdnsSettings()) is False
def test_required_cloudflare_provider_requires_token_and_zone(tmp_path: Path) -> None: def test_required_cloudflare_provider_requires_token_and_zone(tmp_path: Path) -> None:
with pytest.raises(RuntimeError, match="DDNS_CLOUDFLARE_API_TOKEN"): with pytest.raises(RuntimeError, match="DDNS_CLOUDFLARE_API_TOKEN"):
make_app( make_app(
@@ -470,7 +479,7 @@ def test_cloudflare_publish_reconciles_provider_state(monkeypatch) -> None:
return {"id": "record-id", "content": "203.0.113.99"} return {"id": "record-id", "content": "203.0.113.99"}
monkeypatch.setattr(cloudflare, "_cloudflare_find_record", fake_find) monkeypatch.setattr(cloudflare, "_cloudflare_find_record", fake_find)
monkeypatch.setattr(cloudflare, "_cloudflare_request", lambda *args, **kwargs: {"success": True, "result": []}) monkeypatch.setattr(cloudflare, "_cloudflare_request", lambda *args, **_kwargs: {"success": True, "result": []})
with pytest.raises(DnsReconciliationError, match="did not match"): with pytest.raises(DnsReconciliationError, match="did not match"):
cloudflare.publish_cloudflare( cloudflare.publish_cloudflare(
@@ -496,7 +505,7 @@ def test_rfc2136_delete_checks_response_code(monkeypatch) -> None:
def rcode(self) -> int: def rcode(self) -> int:
return dns.rcode.REFUSED return dns.rcode.REFUSED
monkeypatch.setattr(dns.query, "tcp", lambda *args, **kwargs: RefusedResponse()) monkeypatch.setattr(dns.query, "tcp", lambda *args, **_kwargs: RefusedResponse())
with pytest.raises(DnsProviderError, match="RFC2136 delete failed"): with pytest.raises(DnsProviderError, match="RFC2136 delete failed"):
delete_rfc2136_records( delete_rfc2136_records(
@@ -505,6 +514,20 @@ def test_rfc2136_delete_checks_response_code(monkeypatch) -> None:
) )
def test_rfc2136_zone_uses_explicit_setting_or_hostname_parent() -> None:
from karldns.dns.rfc2136 import _rfc2136_zone
assert _rfc2136_zone("home.example.net", DdnsSettings(rfc2136_zone="example.net")) == "example.net"
assert _rfc2136_zone("home.example.net", DdnsSettings()) == "example.net"
def test_rfc2136_relative_name_uses_at_for_zone_apex() -> None:
from karldns.dns.rfc2136 import _rfc2136_relative_name
assert _rfc2136_relative_name("home.example.net", "example.net") == "home"
assert _rfc2136_relative_name("example.net", "example.net") == "@"
def test_invalid_integer_env_value_fails_startup(monkeypatch) -> None: def test_invalid_integer_env_value_fails_startup(monkeypatch) -> None:
monkeypatch.setenv("DDNS_RATE_LIMIT_PER_MINUTE", "not-an-int") monkeypatch.setenv("DDNS_RATE_LIMIT_PER_MINUTE", "not-an-int")
+43
View File
@@ -0,0 +1,43 @@
from karldns.security import (
DashboardUnlockIdentity,
_dashboard_unlock_cookie_parts,
_strip_header_ip,
_trusted_proxy_candidate,
)
def test_dashboard_unlock_identity_payload_is_stable() -> None:
identity = DashboardUnlockIdentity("home.example.net", "manage-secret", "hash")
assert identity.payload(123, 3600) == b"home.example.net\x00manage-secret\x00hash\x00123\x003600"
def test_trusted_proxy_candidate_matches_cidr_and_single_ip() -> None:
assert _trusted_proxy_candidate("10.10.0.1", "10.0.0.0/8") is True
assert _trusted_proxy_candidate("203.0.113.10", "203.0.113.10") is True
def test_trusted_proxy_candidate_ignores_invalid_values() -> None:
assert _trusted_proxy_candidate("not-an-ip", "10.0.0.0/8") is False
assert _trusted_proxy_candidate("10.10.0.1", "not-a-network") is False
def test_dashboard_unlock_cookie_parts_parse_v3_cookie() -> None:
parts = _dashboard_unlock_cookie_parts("v3:123:3600:signature")
assert parts is not None
assert parts.timestamp == 123
assert parts.max_age_seconds == 3600
assert parts.signature == "signature"
def test_dashboard_unlock_cookie_parts_reject_invalid_cookie() -> None:
assert _dashboard_unlock_cookie_parts("v2:123:3600:signature") is None
assert _dashboard_unlock_cookie_parts("v3:not-int:3600:signature") is None
def test_strip_header_ip_handles_common_proxy_formats() -> None:
assert _strip_header_ip('"203.0.113.10"') == "203.0.113.10"
assert _strip_header_ip("[2001:db8::10]:443") == "2001:db8::10"
assert _strip_header_ip("203.0.113.10:443") == "203.0.113.10"
assert _strip_header_ip("unknown") == ""
+16
View File
@@ -0,0 +1,16 @@
import pytest
from karldns.settings import _is_reserved_admin_path, _validate_launch_secrets
def test_reserved_admin_path_matches_exact_and_nested_public_routes() -> None:
assert _is_reserved_admin_path("/api")
assert _is_reserved_admin_path("/api/v1")
assert _is_reserved_admin_path("/logo.png")
assert not _is_reserved_admin_path("/ops")
assert not _is_reserved_admin_path("/apix")
def test_launch_secrets_require_strong_admin_password() -> None:
with pytest.raises(RuntimeError, match="DDNS_ADMIN_PASSWORD"):
_validate_launch_secrets("short", "")
+20
View File
@@ -0,0 +1,20 @@
from karldns.store.metrics import _update_metric_increments
def test_update_metric_increments_include_requested_outcomes() -> None:
increments = _update_metric_increments(
42.5,
is_success=True,
is_dns_failure=True,
is_reconcile_failure=False,
is_bad_ip=True,
)
assert increments == {
"update_requests_total": 1,
"update_latency_ms_total": 42.5,
"update_latency_samples_total": 1,
"update_success_total": 1,
"dns_failures_total": 1,
"bad_ip_total": 1,
}
+35
View File
@@ -0,0 +1,35 @@
from karldns.store.records import RecordUpdateInput, UpdateEventInput, _planned_record_update
def test_planned_record_update_preserves_disabled_ip_versions() -> None:
plan = _planned_record_update(
RecordUpdateInput(
hostname="home.example.net",
ipv4="203.0.113.10",
ipv6=None,
update_ipv4=True,
update_ipv6=False,
),
current_ipv4="198.51.100.4",
current_ipv6="2001:db8::4",
existing=True,
)
assert plan.ipv4 == "203.0.113.10"
assert plan.ipv6 == "2001:db8::4"
assert plan.changed is True
def test_update_event_input_preserves_event_fields() -> None:
event = UpdateEventInput(
hostname="home.example.net",
ipv4="203.0.113.10",
ipv6=None,
status="updated",
detail="ok",
source_ip="198.51.100.1",
)
assert event.hostname == "home.example.net"
assert event.ipv4 == "203.0.113.10"
assert event.source_ip == "198.51.100.1"
+82 -2
View File
@@ -1,9 +1,12 @@
import re import re
import types
from pathlib import Path from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from karldns import DdnsSettings, DdnsStore, make_app from karldns import DdnsSettings, DdnsStore, make_app
from karldns.routers import assets
from karldns.web import scripts, styles
def _csrf(html: str) -> str: def _csrf(html: str) -> str:
@@ -226,6 +229,32 @@ def test_dashboard_link_can_delete_hostname(tmp_path: Path) -> None:
assert update.status_code == 404 assert update.status_code == 404
def test_ui_uses_bearnie_inspired_structural_patterns(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "home")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
home = client.get("/")
dashboard = client.get(f"/m/{account['management_slug']}")
app_css = client.get("/app.css")
assert home.status_code == 200
assert dashboard.status_code == 200
assert app_css.status_code == 200
assert 'data-ui="hero-command"' in home.text
assert 'data-slot="card"' in dashboard.text
assert 'data-slot="card-header"' in dashboard.text
assert 'data-slot="card-content"' in dashboard.text
assert 'class="ui-badge ui-badge-secret"' in dashboard.text
assert 'class="ui-badge ui-badge-public"' in dashboard.text
assert 'class="empty-state"' in dashboard.text
assert "--ui-background:" in app_css.text
assert "--ui-card:" in app_css.text
assert "--ui-muted-foreground:" in app_css.text
def test_management_dashboard_password_locks_dashboard_only(tmp_path: Path) -> None: def test_management_dashboard_password_locks_dashboard_only(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3" database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path) store = DdnsStore(database_path)
@@ -595,9 +624,9 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
assert '<script src="/theme.js?v=2"></script>' in response.text assert '<script src="/theme.js?v=2"></script>' in response.text
assert '<script src="/clipboard.js?v=3" defer></script>' in response.text assert '<script src="/clipboard.js?v=3" defer></script>' in response.text
assert '<script src="/visitor-ip.js?v=1" defer></script>' in response.text assert '<script src="/visitor-ip.js?v=1" defer></script>' in response.text
assert '<script src="/admin-tables.js?v=2" defer></script>' not in response.text assert '<script src="/admin-tables.js?v=3" defer></script>' not in response.text
assert '<link href="/bootstrap-subset.css?v=3" rel="stylesheet">' in response.text assert '<link href="/bootstrap-subset.css?v=3" rel="stylesheet">' in response.text
assert '<link href="/app.css?v=13" rel="stylesheet">' in response.text assert '<link href="/app.css?v=14" rel="stylesheet">' in response.text
assert '<link href="/admin.css" rel="stylesheet">' not in response.text assert '<link href="/admin.css" rel="stylesheet">' not in response.text
assert "cdn.jsdelivr.net/npm/bootstrap" not in response.text assert "cdn.jsdelivr.net/npm/bootstrap" not in response.text
assert "<style>" not in response.text assert "<style>" not in response.text
@@ -629,6 +658,57 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
assert admin_css.headers["cache-control"] == "public, max-age=31536000, immutable" assert admin_css.headers["cache-control"] == "public, max-age=31536000, immutable"
def test_css_and_javascript_assets_are_served_from_files(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
expected_assets = {
"/app.css": "app.css",
"/admin.css": "admin.css",
"/theme.js": "theme.js",
"/clipboard.js": "clipboard.js",
"/admin-tables.js": "admin-tables.js",
"/visitor-ip.js": "visitor-ip.js",
}
for route, filename in expected_assets.items():
asset_file = assets.ASSET_DIR / filename
response = client.get(route)
assert asset_file.exists()
assert response.status_code == 200
assert response.text == asset_file.read_text(encoding="utf-8")
def test_clipboard_asset_has_fallback_and_visible_failure_state(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/clipboard.js")
assert response.status_code == 200
assert 'document.execCommand("copy")' in response.text
assert "Copy failed" in response.text
def test_visitor_ip_asset_fetches_ipv6_only_when_ipv6_placeholder_is_present(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/visitor-ip.js")
assert response.status_code == 200
assert "api6.ipify.org" in response.text
assert 'needsIpv6() || document.querySelector(".terminal-card.manual-code")' not in response.text
def test_web_style_and_script_modules_do_not_embed_assets() -> None:
assert [name for name, value in vars(styles).items() if name.endswith("_CSS") and isinstance(value, str)] == []
assert [name for name, value in vars(scripts).items() if name.endswith("_JS") and isinstance(value, str)] == []
assert isinstance(styles, types.ModuleType)
assert isinstance(scripts, types.ModuleType)
def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None: def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
app = make_app( app = make_app(
DdnsSettings( DdnsSettings(
+62
View File
@@ -55,6 +55,68 @@ def test_router_update_post_accepts_form_and_json(tmp_path: Path) -> None:
assert store.list_records()[0]["ipv4"] == "203.0.113.10" assert store.list_records()[0]["ipv4"] == "203.0.113.10"
def test_router_post_json_parser_keeps_only_string_values() -> None:
from karldns.routers.updates import _json_post_values
values = _json_post_values(b'{"myip":"203.0.113.10","count":1,"empty":"","nested":{"x":1}}')
assert values == {"myip": "203.0.113.10", "empty": ""}
def test_router_post_form_parser_handles_invalid_utf8_as_empty() -> None:
from karldns.routers.updates import _form_post_values
assert _form_post_values(b"\xff") == {}
def test_router_ip_params_merge_body_without_overriding_query_values() -> None:
from karldns.routers.updates import _IpParams
params = _IpParams(myip="203.0.113.8", ipaddr=None, myipv6="", ip6addr=None)
merged = params.with_body_values(
{
"myip": "203.0.113.9",
"ipaddr": "198.51.100.9",
"myipv6": "2001:db8::9",
}
)
assert merged == _IpParams(
myip="203.0.113.8",
ipaddr="198.51.100.9",
myipv6="2001:db8::9",
ip6addr=None,
)
def test_router_ip_params_from_query_builds_domain_params() -> None:
from karldns.routers.updates import _ip_params_from_query
from karldns.services.ddns import UpdateIpParams
assert _ip_params_from_query(myip="203.0.113.8", ipaddr=None, myipv6=None, ip6addr="2001:db8::8") == (
UpdateIpParams(myip="203.0.113.8", ipaddr=None, myipv6=None, ip6addr="2001:db8::8")
)
def test_registration_hostname_rejects_disabled_or_unmanaged_accounts() -> None:
from fastapi import HTTPException
from karldns.routers.updates import _registration_hostname
class Service:
def hostname_is_managed(self, hostname: str) -> bool:
return hostname.endswith(".managed.example")
assert _registration_hostname({"hostname": "home.managed.example", "disabled": False}, Service()) == (
"home.managed.example"
)
with pytest.raises(HTTPException):
_registration_hostname({"hostname": "home.managed.example", "disabled": True}, Service())
with pytest.raises(HTTPException):
_registration_hostname({"hostname": "home.other.example", "disabled": False}, Service())
def test_router_update_requires_registered_hostname(tmp_path: Path) -> None: def test_router_update_requires_registered_hostname(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3" database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path) store = DdnsStore(database_path)
+30
View File
@@ -0,0 +1,30 @@
from karldns.web.html_utils import SafeHtml
from karldns.web.sections.public_home import (
_manual_updates_section,
_public_home_hero,
_setup_flow_section,
)
def test_public_home_sections_render_independently() -> None:
hero = _public_home_hero(
suffix="ddns.example.net",
demo_update_url="https://karldns.example/api/v1/router/update/<secret>?myip=<ipaddr>",
)
setup = _setup_flow_section(
create_demo="curl -sS 'https://karldns.example/api/v1/router/update/<secret>?myip=203.0.113.10'",
create_demo_command="curl -sS 'https://karldns.example/api/v1/router/update/<secret>?myip=203.0.113.10'",
)
manual = _manual_updates_section(
manual_demo="# explicit IPv4\ncurl -sS 'https://karldns.example/api/v1/router/update/<secret>?myip=203.0.113.10'",
)
assert isinstance(hero, SafeHtml)
assert 'data-ui="hero-command"' in hero
assert "ddns.example.net" in hero
assert isinstance(setup, SafeHtml)
assert "KarlDNS setup flow" in setup
assert "Copy update example" in setup
assert isinstance(manual, SafeHtml)
assert 'id="manual-updates"' in manual
assert "Copy manual update examples" in manual