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

+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 .security import credentials_from_basic_auth
from .services.ddns import BadIpError, DdnsService
from .services.ddns import BadIpError, DdnsService, UpdateIpParams
from .settings import DdnsSettings
from .store import DdnsStore
@@ -38,11 +38,8 @@ def authenticate_admin(
if not settings.admin_password:
raise HTTPException(status_code=500, detail="DDNS_ADMIN_PASSWORD is required")
supplied = None
if authorization and authorization.lower().startswith("basic "):
_, supplied = credentials_from_basic_auth(authorization)
if not supplied or not secrets.compare_digest(supplied, settings.admin_password):
supplied = _basic_auth_password(authorization)
if supplied is None or not secrets.compare_digest(supplied, settings.admin_password):
raise HTTPException(
status_code=401,
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(
service: DdnsService,
request: Request,
hostname: str,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
ip_params: UpdateIpParams,
) -> str | PlainTextResponse:
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:
return PlainTextResponse("badip", status_code=400)
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:
validate_dns_provider_settings(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:
@@ -65,19 +65,33 @@ def validate_dns_provider_settings(settings: DdnsSettings) -> None:
if not settings.require_dns_provider:
return
if provider == "cloudflare":
if not settings.cloudflare_api_token or not settings.cloudflare_zone_id:
raise DnsConfigurationError(
"DDNS_CLOUDFLARE_API_TOKEN and DDNS_CLOUDFLARE_ZONE_ID are required when DDNS_DNS_PROVIDER=cloudflare"
)
_validate_cloudflare_settings(settings)
return
if provider == "rfc2136" or settings.rfc2136_server:
if not settings.rfc2136_server or not settings.rfc2136_zone:
raise DnsConfigurationError(
"DDNS_RFC2136_SERVER and DDNS_RFC2136_ZONE are required when RFC2136 DNS publishing is enabled"
)
if _dns_backend_enabled(provider, settings):
_validate_rfc2136_settings(settings)
return
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:
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.add_header("Authorization", f"Bearer {settings.cloudflare_api_token}")
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:
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:
raise DnsProviderError(f"Cloudflare API returned HTTP {exc.code}") from exc
except urllib.error.URLError as exc:
raise DnsProviderError("Cloudflare API request failed") from exc
except json.JSONDecodeError as 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):
raise DnsProviderError("Cloudflare API returned an unsuccessful response")
return parsed
+89 -42
View File
@@ -1,67 +1,114 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Protocol
from ..models import UpdateResult
from ..settings import DdnsSettings
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:
if not settings.rfc2136_server:
return
import dns.exception
import dns.query
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(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)}")
update, relative = _rfc2136_update(result.hostname, settings)
_apply_update_records(update, relative, result, settings)
_send_rfc2136_update(update, settings.rfc2136_server, "update")
def delete_rfc2136_records(hostname: str, settings: DdnsSettings) -> None:
if not settings.rfc2136_server:
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.query
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:
response = dns.query.tcp(update, settings.rfc2136_server, timeout=5)
response = dns.query.tcp(update, server, timeout=5)
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()
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 typing import Annotated
from urllib.parse import urlencode
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.forms import first_form_value, request_form
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_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:
router = APIRouter()
def render_admin(
service: ServiceDep,
settings: SettingsDep,
store: StoreDep,
table_offsets: dict[str, int] | None = None,
notice: SafeHtml | None = None,
) -> str:
table_offsets = table_offsets or {}
return _render_admin_page(
service,
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,
)
context = _admin_page_context(store, table_offsets, ADMIN_TABLE_LIMIT, notice)
return _render_admin_page(settings, context)
@router.get(admin_path, response_class=HTMLResponse, include_in_schema=False)
def admin(
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: None = Depends(authenticate_admin),
accounts_offset: int = Query(0, ge=0),
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),
_: AdminAuthDep,
table_offsets: AdminTableOffsetsDep,
notice: AdminCleanupNoticeDep,
) -> str:
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,
)
return render_admin(settings, store, table_offsets, notice)
@router.post(f"{admin_path}/hostnames/delete", include_in_schema=False)
async def admin_delete_account(
@@ -91,7 +87,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: None = Depends(authenticate_admin),
_: AdminAuthDep,
) -> Response:
form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf"))
@@ -118,7 +114,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: None = Depends(authenticate_admin),
_: AdminAuthDep,
) -> Response:
form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf"))
@@ -168,7 +164,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
)
else:
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)
@router.post(f"{admin_path}/cleanup", include_in_schema=False)
@@ -176,8 +172,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: None = Depends(authenticate_admin),
_: AdminAuthDep,
) -> Response:
form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf"))
+29 -15
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from starlette.concurrency import run_in_threadpool
@@ -33,6 +35,12 @@ router = APIRouter(prefix="/api/v1")
ADMIN_API_LIMIT_CAP = 500
ADMIN_API_OFFSET_CAP = 10_000
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(
@@ -52,7 +60,10 @@ async def create_hostname(service: ServiceDep) -> HostnameCreateResponse:
"/hostnames/{dashboard_secret}",
response_model=HostnameStatusResponse,
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"],
responses={404: {"model": ErrorResponse}},
)
@@ -60,7 +71,7 @@ async def read_hostname(
dashboard_secret: ManagementSlugPath,
store: StoreDep,
service: ServiceDep,
history_limit: int = Query(10, ge=0, le=HOSTNAME_HISTORY_LIMIT_CAP),
history_limit: HostnameHistoryLimitQuery = 10,
) -> HostnameStatusResponse:
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
if not account or account["disabled"]:
@@ -113,7 +124,10 @@ async def rotate_hostname_update_url(
"/hostnames/{dashboard_secret}",
status_code=status.HTTP_204_NO_CONTENT,
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"],
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
@@ -140,8 +154,8 @@ async def delete_hostname(dashboard_secret: ManagementSlugPath, store: StoreDep,
)
async def read_admin(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(25, ge=1, le=ADMIN_API_LIMIT_CAP),
_: AdminAuthDep,
limit: AdminDashboardLimitQuery = 25,
) -> AdminDashboardResponse:
metrics = await run_in_threadpool(store.get_service_metrics)
accounts = await run_in_threadpool(store.list_accounts, limit, 0)
@@ -175,9 +189,9 @@ async def read_admin(
)
async def list_admin_hostnames(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
_: AdminAuthDep,
limit: AdminLimitQuery = 100,
offset: AdminOffsetQuery = 0,
) -> AdminHostnamesResponse:
accounts = await run_in_threadpool(store.list_accounts, limit, offset)
total = await run_in_threadpool(store.count_accounts)
@@ -200,10 +214,10 @@ async def list_admin_hostnames(
)
async def list_admin_events(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
before: str | None = Query(None, max_length=64),
_: AdminAuthDep,
limit: AdminLimitQuery = 100,
offset: AdminOffsetQuery = 0,
before: AdminBeforeQuery = None,
) -> AdminEventsResponse:
items = (
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(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
_: AdminAuthDep,
limit: AdminLimitQuery = 100,
offset: AdminOffsetQuery = 0,
) -> AdminRecordsResponse:
items = await run_in_threadpool(store.list_records_page, limit, offset)
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.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"
router = APIRouter(include_in_schema=False)
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")
def favicon() -> FileResponse:
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")
def theme_js() -> Response:
return Response(THEME_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
def theme_js() -> FileResponse:
return FileResponse(ASSET_DIR / "theme.js", media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/clipboard.js")
def clipboard_js() -> Response:
return Response(CLIPBOARD_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
def clipboard_js() -> FileResponse:
return FileResponse(ASSET_DIR / "clipboard.js", media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/admin-tables.js")
def admin_tables_js() -> Response:
return Response(ADMIN_TABLES_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
def admin_tables_js() -> FileResponse:
return FileResponse(ASSET_DIR / "admin-tables.js", media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/visitor-ip.js")
def visitor_ip_js() -> Response:
return Response(VISITOR_IP_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
def visitor_ip_js() -> FileResponse:
return FileResponse(ASSET_DIR / "visitor-ip.js", media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/app.css")
def app_css() -> Response:
return Response(MINIFIED_BASE_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS)
def app_css() -> FileResponse:
return FileResponse(ASSET_DIR / "app.css", media_type="text/css", headers=ASSET_CACHE_HEADERS)
@router.get("/admin.css")
def admin_css() -> Response:
return Response(MINIFIED_ADMIN_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS)
def admin_css() -> FileResponse:
return FileResponse(ASSET_DIR / "admin.css", media_type="text/css", headers=ASSET_CACHE_HEADERS)
@router.get("/install.sh")
+78 -45
View File
@@ -1,8 +1,8 @@
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 starlette.concurrency import run_in_threadpool
@@ -14,11 +14,13 @@ from ..event_statuses import (
DASHBOARD_UPDATE_URL_ROTATED,
)
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.html_utils import trusted
from ..web.layout import _page
from ..web.templates_management import (
ManagementPageContext,
PendingManagementPageContext,
_render_management_page,
_render_management_unlock_page,
_render_pending_management_page,
@@ -36,6 +38,19 @@ from .path_params import ManagementSlugPath
router = APIRouter()
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)
@@ -43,27 +58,34 @@ def manage_dashboard(
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
activated: bool = False,
) -> 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)
if not management_account_is_accepted(account):
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):
return _render_management_unlock_page(management_slug, account, csrf_token)
message = "Hostname registered. Add the update URL to your router." if activated else None
return _render_management_page(
service,
settings,
management_slug,
account,
csrf_token,
message=message,
request_ip=client_ip(request, settings),
ManagementPageContext(
management_slug=management_slug,
account=account,
csrf_token=csrf_token,
message=message,
request_ip=client_ip(request, settings),
),
)
@@ -72,30 +94,36 @@ async def activate_dashboard(
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
myip: str | None = None,
ipaddr: str | None = None,
myipv6: str | None = None,
ip6addr: str | None = None,
ip_params: ActivationIpParamsDep,
) -> 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)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
if not management_account_is_accepted(account):
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:
return HTMLResponse(
_render_pending_management_page(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
"The supplied IP address is invalid. Leave IP fields empty and let the router send the first update.",
client_ip(request, settings),
PendingManagementPageContext(
settings=settings,
management_slug=management_slug,
account=account,
csrf_token=dashboard_csrf_token(management_slug, account),
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,
)
@@ -103,20 +131,22 @@ async def activate_dashboard(
return HTMLResponse(
_render_pending_management_page(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
"DNS registration failed. The hostname was not registered.",
client_ip(request, settings),
PendingManagementPageContext(
settings=settings,
management_slug=management_slug,
account=account,
csrf_token=dashboard_csrf_token(management_slug, account),
message="DNS registration failed. The hostname was not registered.",
request_ip=client_ip(request, settings),
),
),
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:
return _not_found()
await run_in_threadpool(
store.log_update_event,
service.store.log_update_event,
hostname,
None,
None,
@@ -140,12 +170,14 @@ async def rotate_dashboard_secrets(
return HTMLResponse(
_render_pending_management_page(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
"Register this hostname first.",
client_ip(request, settings),
PendingManagementPageContext(
settings=settings,
management_slug=management_slug,
account=account,
csrf_token=dashboard_csrf_token(management_slug, account),
message="Register this hostname first.",
request_ip=client_ip(request, settings),
),
),
status_code=400,
)
@@ -171,12 +203,13 @@ async def rotate_dashboard_secrets(
)
return await render_management_page_async(
service,
settings,
management_slug,
rotated,
dashboard_csrf_token(management_slug, rotated),
message="Update URL changed. Update your router now.",
request_ip=request_ip,
ManagementPageContext(
management_slug=management_slug,
account=rotated,
csrf_token=dashboard_csrf_token(management_slug, rotated),
message="Update URL changed. Update your router now.",
request_ip=request_ip,
),
)
raise _bad_rotation_action()
+84 -40
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from fastapi import APIRouter, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
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 ..event_statuses import DASHBOARD_PASSWORD_CHANGED, DASHBOARD_PASSWORD_ENABLED, DASHBOARD_PASSWORD_REMOVED
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.templates_management import _render_management_unlock_page
from ..web.templates_management import ManagementPageContext, _render_management_unlock_page
from .management_shared import (
ManagementAccount,
delete_unlock_cookie,
get_active_management_account_async,
has_management_access,
@@ -23,6 +28,15 @@ from .path_params import ManagementSlugPath
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)
async def unlock_dashboard(
management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep
@@ -70,45 +84,21 @@ async def set_dashboard_password(
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
if account.get("dashboard_password_hash"):
is_current = await run_in_threadpool(
store.verify_dashboard_password,
hostname,
first_form_value(form, "current_dashboard_password"),
)
if not is_current:
return await management_error_response_async(
service,
settings,
management_slug,
account,
"Current password is incorrect.",
client_ip(request, settings),
)
current_password_error = await _verify_current_dashboard_password(store, hostname, account, form)
request_ip = client_ip(request, settings)
error_response = await _dashboard_password_error_response(
service,
_DashboardPasswordErrorContext(
management_slug=management_slug,
account=account,
form=form,
request_ip=request_ip,
current_password_error=current_password_error,
),
)
if error_response:
return error_response
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"))
await run_in_threadpool(store.set_dashboard_password, hostname, password)
account = await get_active_management_account_async(store, management_slug)
@@ -126,6 +116,61 @@ async def set_dashboard_password(
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)
async def remove_dashboard_password(
management_slug: ManagementSlugPath,
@@ -152,7 +197,6 @@ async def remove_dashboard_password(
if not is_current:
return await management_error_response_async(
service,
settings,
management_slug,
account,
"Current password is incorrect.",
+13 -27
View File
@@ -6,6 +6,7 @@ from starlette.concurrency import run_in_threadpool
from ..security import (
DASHBOARD_UNLOCK_COOKIE,
DashboardUnlockIdentity,
dashboard_cookie_signing_secret,
dashboard_csrf_token,
sign_dashboard_unlock_cookie,
@@ -15,7 +16,7 @@ from ..services.ddns import DdnsService
from ..settings import DdnsSettings
from ..store import DdnsStore
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
@@ -59,7 +60,6 @@ def locked_management_response(management_slug: str, account: ManagementAccount)
def management_error_response(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: ManagementAccount,
message: str,
@@ -68,12 +68,13 @@ def management_error_response(
return HTMLResponse(
_render_management_page(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message=message,
request_ip=request_ip,
ManagementPageContext(
management_slug=management_slug,
account=account,
csrf_token=dashboard_csrf_token(management_slug, account),
message=message,
request_ip=request_ip,
),
),
status_code=400,
)
@@ -81,35 +82,22 @@ def management_error_response(
async def management_error_response_async(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: ManagementAccount,
message: str,
request_ip: str | None = None,
) -> HTMLResponse:
return await run_in_threadpool(
management_error_response, service, settings, management_slug, account, message, request_ip
)
return await run_in_threadpool(management_error_response, service, management_slug, account, message, request_ip)
async def render_management_page_async(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: ManagementAccount,
csrf_token: str,
message: str | None = None,
request_ip: str | None = None,
context: ManagementPageContext,
) -> str:
return await run_in_threadpool(
_render_management_page,
service,
settings,
management_slug,
account,
csrf_token,
message,
request_ip,
context,
)
@@ -127,9 +115,7 @@ def set_unlock_cookie(
response.set_cookie(
DASHBOARD_UNLOCK_COOKIE,
sign_dashboard_unlock_cookie(
str(account["hostname"]),
management_slug,
str(password_hash),
DashboardUnlockIdentity(str(account["hostname"]), management_slug, str(password_hash)),
signing_secret,
max_age_seconds,
),
+136 -86
View File
@@ -1,20 +1,36 @@
from __future__ import annotations
import json
from collections.abc import Mapping
from typing import Annotated
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 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 ..event_statuses import DASHBOARD_HOSTNAME_ACTIVATED
from ..security import client_ip
from ..services.ddns import BadIpError
from ..services.ddns import BadIpError, DdnsService, UpdateIpParams
from .path_params import ManagementSlugPath, UpdateSlugPath
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(
@@ -27,14 +43,15 @@ router = APIRouter()
async def register_hostname_get(
dashboard_secret: ManagementSlugPath,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None = Query(default=None),
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
ip_params: RouterIpParamsDep,
) -> 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(
@@ -47,45 +64,76 @@ async def register_hostname_get(
async def register_hostname_post(
dashboard_secret: ManagementSlugPath,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None = Query(default=None),
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
ip_params: RouterIpParamsDep,
) -> 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(
dashboard_secret: str,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
ip_params: _IpParams,
) -> str | PlainTextResponse:
myip, ipaddr, myipv6, ip6addr = await _ip_params_from_request(request, myip, ipaddr, myipv6, ip6addr)
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
if not account or account["disabled"]:
ip_params = await _ip_params_from_request(request, ip_params)
account = await run_in_threadpool(service.store.get_account_by_management_slug, dashboard_secret)
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")
hostname = str(account["hostname"])
if not service.hostname_is_managed(hostname):
raise HTTPException(status_code=404, detail="not found")
if not account["accepted_at"]:
try:
await run_in_threadpool(service.publish_initial_dns, request, hostname, myip, ipaddr, myipv6, ip6addr)
except BadIpError as exc:
return PlainTextResponse(f"badip {exc}", status_code=400)
except DnsProviderError:
return PlainTextResponse("911 dns registration failed", status_code=500)
activated = await run_in_threadpool(store.activate_account, hostname)
if not activated:
raise HTTPException(status_code=404, detail="not found")
return hostname
async def _publish_initial_registration_dns(
request: Request,
service: DdnsService,
account: Mapping[str, object],
hostname: str,
ip_params: _IpParams,
) -> 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(
store.log_update_event,
service.store.log_update_event,
hostname,
None,
None,
@@ -93,7 +141,6 @@ async def _register_hostname(
"hostname registered",
client_ip(request, service.settings),
)
return "good registered"
@router.get(
@@ -106,14 +153,15 @@ async def _register_hostname(
async def update_hostname_get(
update_slug: UpdateSlugPath,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None = Query(default=None),
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
ip_params: RouterIpParamsDep,
) -> 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(
@@ -126,76 +174,78 @@ async def update_hostname_get(
async def update_hostname_post(
update_slug: UpdateSlugPath,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None = Query(default=None),
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
ip_params: RouterIpParamsDep,
) -> 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(
update_slug: str,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
ip_params: _IpParams,
) -> str | PlainTextResponse:
myip, ipaddr, myipv6, ip6addr = await _ip_params_from_request(request, myip, ipaddr, myipv6, ip6addr)
account = await run_in_threadpool(store.get_account_by_slug, update_slug)
ip_params = await _ip_params_from_request(request, ip_params)
account = await run_in_threadpool(service.store.get_account_by_slug, update_slug)
if not account or account["disabled"] or not account["accepted_at"]:
raise HTTPException(status_code=404, detail="not found")
if not service.hostname_is_managed(str(account["hostname"])):
raise HTTPException(status_code=404, detail="not found")
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(
request: Request,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> tuple[str | None, str | None, str | None, str | None]:
async def _ip_params_from_request(request: Request, ip_params: _IpParams) -> _IpParams:
if request.method != "POST":
return myip, ipaddr, myipv6, ip6addr
return ip_params
body_values = await _post_values(request)
return (
myip or body_values.get("myip"),
ipaddr or body_values.get("ipaddr"),
myipv6 or body_values.get("myipv6"),
ip6addr or body_values.get("ip6addr"),
)
return ip_params.with_body_values(body_values)
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()
if not body:
return {}
content_type = _request_content_type(request)
if content_type == "application/json":
try:
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)}
return _json_post_values(body)
if content_type == "application/x-www-form-urlencoded":
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}
return _form_post_values(body)
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],
history: Sequence[Mapping[str, object]] | None = None,
) -> HostnameStatusResponse:
account_for_url = {"update_slug": str(account["update_slug"])}
account_for_dashboard = {"management_slug": str(account.get("management_slug", ""))}
return HostnameStatusResponse(
hostname=str(account["hostname"]),
status="active" if account.get("accepted_at") else "pending",
update_url=service.fritz_update_url(account_for_url),
dashboard_url=service.dashboard_url(account_for_dashboard) if account_for_dashboard["management_slug"] else "",
ipv4=str(account["ipv4"]) if account.get("ipv4") else None,
ipv6=str(account["ipv6"]) if account.get("ipv6") else None,
updated_at=str(account["updated_at"]) if account.get("updated_at") else None,
status=_account_status(account, include_disabled=False),
update_url=service.fritz_update_url({"update_slug": str(account["update_slug"])}),
dashboard_url=_dashboard_url(service, account),
ipv4=_row_text(account, "ipv4"),
ipv6=_row_text(account, "ipv6"),
updated_at=_row_text(account, "updated_at"),
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:
return HostnameUpdateEvent(
hostname=str(row["hostname"]),
ipv4=str(row["ipv4"]) if row.get("ipv4") else None,
ipv6=str(row["ipv6"]) if row.get("ipv6") else None,
ipv4=_row_text(row, "ipv4"),
ipv6=_row_text(row, "ipv6"),
status=str(row["status"]),
detail=str(row["detail"]),
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:
return AdminHostname(
hostname=str(account["hostname"]),
status="disabled" if account.get("disabled") else ("active" if account.get("accepted_at") else "pending"),
ipv4=str(account["ipv4"]) if account.get("ipv4") else None,
ipv6=str(account["ipv6"]) if account.get("ipv6") else None,
updated_at=str(account["updated_at"]) if account.get("updated_at") else None,
created_at=str(account["created_at"]) if account.get("created_at") else None,
accepted_at=str(account["accepted_at"]) if account.get("accepted_at") else None,
status=_account_status(account),
ipv4=_row_text(account, "ipv4"),
ipv6=_row_text(account, "ipv6"),
updated_at=_row_text(account, "updated_at"),
created_at=_row_text(account, "created_at"),
accepted_at=_row_text(account, "accepted_at"),
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:
return AdminRecord(
hostname=str(row["hostname"]),
ipv4=str(row["ipv4"]) if row.get("ipv4") else None,
ipv6=str(row["ipv6"]) if row.get("ipv6") else None,
ipv4=_row_text(row, "ipv4"),
ipv6=_row_text(row, "ipv6"),
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:
return AdminUpdateEvent(
hostname=str(row["hostname"]),
ipv4=str(row["ipv4"]) if row.get("ipv4") else None,
ipv6=str(row["ipv6"]) if row.get("ipv6") else None,
ipv4=_row_text(row, "ipv4"),
ipv6=_row_text(row, "ipv6"),
status=str(row["status"]),
detail=str(row["detail"]),
source_ip=str(row["source_ip"]),
@@ -243,16 +241,36 @@ def _admin_scheduled_task(row: Mapping[str, object]) -> AdminScheduledTask:
name=str(row["name"]),
interval_seconds=_int_value(row["interval_seconds"], 0),
next_run_at=str(row["next_run_at"]),
lease_owner=str(row["lease_owner"]) if row.get("lease_owner") else None,
lease_expires_at=str(row["lease_expires_at"]) if row.get("lease_expires_at") else None,
last_started_at=str(row["last_started_at"]) if row.get("last_started_at") else None,
last_finished_at=str(row["last_finished_at"]) if row.get("last_finished_at") else None,
lease_owner=_row_text(row, "lease_owner"),
lease_expires_at=_row_text(row, "lease_expires_at"),
last_started_at=_row_text(row, "last_started_at"),
last_finished_at=_row_text(row, "last_finished_at"),
last_status=str(row["last_status"]),
last_error=str(row["last_error"]),
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:
if value is None:
return default
+98 -45
View File
@@ -6,6 +6,7 @@ import hmac
import secrets
import time
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
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]:
try:
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
@@ -69,9 +87,7 @@ def new_bearer_secret() -> str:
def sign_dashboard_unlock_cookie(
hostname: str,
management_slug: str,
password_hash: str,
identity: DashboardUnlockIdentity,
signing_secret: str,
max_age_seconds: int,
now: int | None = None,
@@ -79,9 +95,7 @@ def sign_dashboard_unlock_cookie(
timestamp = int(time.time() if now is None else now)
bounded_max_age = _bounded_dashboard_unlock_seconds(max_age_seconds)
signature = _dashboard_unlock_signature(
hostname,
management_slug,
password_hash,
identity,
signing_secret,
timestamp,
bounded_max_age,
@@ -96,40 +110,63 @@ def verify_dashboard_unlock_cookie(
signing_secret: str,
cookie_value: str | None,
) -> 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
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:
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)
max_age_seconds = _bounded_dashboard_unlock_seconds(int(raw_max_age))
except ValueError:
return False
return None
if version != "v3":
return False
now = int(time.time())
if timestamp > now + 60 or now - timestamp > max_age_seconds:
return False
expected = _dashboard_unlock_signature(
hostname,
management_slug,
password_hash,
signing_secret,
timestamp,
max_age_seconds,
)
return hmac.compare_digest(supplied, expected)
return None
return _DashboardUnlockCookieParts(timestamp, max_age_seconds, signature)
def _dashboard_unlock_cookie_expired(parts: _DashboardUnlockCookieParts, now: int) -> bool:
return parts.timestamp > now + 60 or now - parts.timestamp > parts.max_age_seconds
def _dashboard_unlock_signature(
hostname: str,
management_slug: str,
password_hash: str,
identity: DashboardUnlockIdentity,
signing_secret: str,
timestamp: int,
max_age_seconds: int,
) -> str:
payload = f"{hostname}\0{management_slug}\0{password_hash}\0{timestamp}\0{max_age_seconds}".encode()
return hmac.new(signing_secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.new(signing_secret.encode(), identity.payload(timestamp, max_age_seconds), hashlib.sha256).hexdigest()
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:
if not trusted:
return False
return any(_trusted_proxy_candidate(peer, candidate) for candidate in trusted)
def _trusted_proxy_candidate(peer: str, candidate: str) -> bool:
try:
peer_ip = ip_address(peer)
except ValueError:
return False
for candidate in trusted:
try:
if "/" in candidate and peer_ip in ip_network(candidate, strict=False):
return True
if "/" not in candidate and peer_ip == ip_address(candidate):
return True
except ValueError:
continue
return False
try:
if "/" in candidate:
return peer_ip in ip_network(candidate, strict=False)
return peer_ip == ip_address(candidate)
except ValueError:
return False
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:
cleaned = value.strip().strip('"').strip("'")
if not cleaned or cleaned.lower() == "unknown":
cleaned = _strip_header_ip_quotes(value)
if _empty_header_ip(cleaned):
return ""
if cleaned.startswith("[") and "]" in cleaned:
return cleaned[1 : cleaned.index("]")]
if cleaned.count(":") == 1:
host, port = cleaned.rsplit(":", 1)
if port.isdigit():
return host
return cleaned
return _strip_bracketed_ip(cleaned) or _strip_host_port(cleaned) or cleaned
def _strip_header_ip_quotes(value: str) -> str:
return value.strip().strip('"').strip("'")
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
@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):
"""Raised when a supplied DynDNS IP parameter is invalid."""
@@ -71,36 +100,21 @@ class DdnsService:
self,
request: Request,
hostname: str,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
ip_params: UpdateIpParams,
) -> bool:
if not dns_backend_configured(self.settings):
return False
source_ip = client_ip(request, self.settings)
source_fallback = source_ip if _is_global_ip(source_ip) else ""
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"
plan = _initial_dns_plan(source_ip, *ip_params.initial_plan_args)
lock = self._lock_for(hostname)
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:
try:
publish_dns(planned, self.settings)
@@ -113,7 +127,12 @@ class DdnsService:
type(exc).__name__,
)
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
except DnsProviderError as exc:
@@ -125,21 +144,23 @@ class DdnsService:
type(exc).__name__,
)
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
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
def apply_update(
self,
request: Request,
hostname: str,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
ip_params: UpdateIpParams,
) -> UpdateOutcome:
started_at = time.perf_counter()
is_success = False
@@ -150,10 +171,7 @@ class DdnsService:
try:
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
source_ip,
myip,
ipaddr,
myipv6,
ip6addr,
*ip_params.initial_plan_args,
)
lock = self._lock_for(hostname)
with lock:
@@ -248,13 +266,8 @@ class DdnsService:
def normalize_ip(value: str | None, version: int | None = None) -> str | None:
if not value or not value.strip():
return None
try:
ip = ipaddress.ip_address(value.strip())
except ValueError:
return None
if version and ip.version != version:
ip = _optional_ip(value)
if ip is None or (version and ip.version != version):
return None
return str(ip)
@@ -280,6 +293,42 @@ def parse_update_ips(
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:
if not value:
return False
@@ -294,17 +343,31 @@ def _first_valid_ip(params: tuple[tuple[str, str | None], ...], version: int) ->
for name, value in params:
if value is None or not value.strip():
continue
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")
ip_value = _required_ip_param(name, value, version)
if selected is None:
selected = str(ip)
selected = ip_value
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:
hostname = value.strip().lower().rstrip(".")
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
_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):
model_config = ConfigDict(extra="forbid")
@@ -42,52 +70,25 @@ class DdnsSettings(BaseModel):
@field_validator("public_base_url")
@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://")):
raise ValueError("public_base_url must start with http:// or https://")
return value.rstrip("/")
@field_validator("trusted_hosts")
@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()}
@field_validator("admin_path")
@classmethod
def validate_admin_path(cls, value: str) -> str:
def validate_admin_path(_cls, value: str) -> str:
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 /")
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")
reserved_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",
}
if any(path == reserved or path.startswith(f"{reserved}/") for reserved in reserved_paths):
if _is_reserved_admin_path(path):
raise ValueError("admin_path must not conflict with a public route")
return path
@@ -107,19 +108,44 @@ class DdnsSettings(BaseModel):
validate_dns_provider_settings(self)
if not self.require_dns_provider:
return
if len(self.admin_password) < 16:
raise RuntimeError("DDNS_ADMIN_PASSWORD must be at least 16 characters when DDNS_REQUIRE_DNS_PROVIDER=1")
if len(self.dashboard_cookie_secret or self.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"
)
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")
_validate_launch_secrets(self.admin_password, self.dashboard_cookie_secret)
_validate_launch_hostname_suffix(self.hostname_suffix)
_validate_launch_trusted_hosts(self.effective_trusted_hosts)
@classmethod
def from_env(cls) -> DdnsSettings:
def from_env(_cls) -> DdnsSettings:
from .config import 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):
def create_account(self, hostname: str, username: str | None = None, *, accepted: bool = True) -> AccountRow:
_ = username
now = datetime.now(UTC).isoformat()
update_slug = new_bearer_secret()
management_slug = new_bearer_secret()
@@ -27,6 +28,7 @@ class AccountsMixin(StoreConnectionMixin):
"update_slug": update_slug,
"management_slug": management_slug,
"accepted_at": accepted_at,
"disabled": 0,
}
def list_accounts(self, limit: int = 100, offset: int = 0) -> list[AccountRow]:
@@ -34,7 +36,8 @@ class AccountsMixin(StoreConnectionMixin):
rows = conn.execute(
"""
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
LEFT JOIN records ON records.hostname = accounts.hostname
ORDER BY accounts.hostname
@@ -54,7 +57,8 @@ class AccountsMixin(StoreConnectionMixin):
account = conn.execute(
"""
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
LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.hostname = ?
@@ -79,7 +83,8 @@ class AccountsMixin(StoreConnectionMixin):
account = conn.execute(
"""
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
LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.management_hash = ?
+30 -13
View File
@@ -5,6 +5,29 @@ from datetime import UTC, datetime
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):
def increment_metric(self, name: str, amount: float = 1.0) -> None:
now = datetime.now(UTC).isoformat()
@@ -48,19 +71,13 @@ class MetricsMixin(StoreConnectionMixin):
is_reconcile_failure: bool = False,
is_bad_ip: bool = False,
) -> None:
increments = {
"update_requests_total": 1,
"update_latency_ms_total": latency_ms,
"update_latency_samples_total": 1,
}
if is_success:
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
increments = _update_metric_increments(
latency_ms,
is_success=is_success,
is_dns_failure=is_dns_failure,
is_reconcile_failure=is_reconcile_failure,
is_bad_ip=is_bad_ip,
)
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
+5 -3
View File
@@ -199,9 +199,11 @@ class RateLimitCleanupMixin(StoreConnectionMixin):
[
(
row["hostname"],
CLEANUP_PENDING_HOSTNAME_DELETED
if row["cleanup_reason"] == "pending"
else CLEANUP_UNUSED_HOSTNAME_DELETED,
(
CLEANUP_PENDING_HOSTNAME_DELETED
if row["cleanup_reason"] == "pending"
else CLEANUP_UNUSED_HOSTNAME_DELETED
),
"cleanup deleted abandoned hostname",
"127.0.0.1",
datetime.now(UTC).isoformat(),
+93 -42
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from datetime import UTC, datetime
from ..models import UpdateResult
@@ -8,6 +9,60 @@ from .base import StoreConnectionMixin
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):
def upsert(
self,
@@ -18,13 +73,17 @@ class RecordsEventsMixin(StoreConnectionMixin):
update_ipv6: bool = True,
) -> UpdateResult:
now = datetime.now(UTC).isoformat()
update = RecordUpdateInput(hostname, ipv4, ipv6, update_ipv4, update_ipv6)
with self.connect() as conn:
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
current_ipv4 = existing["ipv4"] if existing else None
current_ipv6 = existing["ipv6"] if existing else None
effective_ipv4 = ipv4 if update_ipv4 else current_ipv4
effective_ipv6 = ipv6 if update_ipv6 else current_ipv6
changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6
result = _planned_record_update(
update,
current_ipv4=current_ipv4,
current_ipv6=current_ipv6,
existing=existing is not None,
)
conn.execute(
"""
INSERT INTO records(hostname, ipv4, ipv6, updated_at)
@@ -34,16 +93,9 @@ class RecordsEventsMixin(StoreConnectionMixin):
ipv6=excluded.ipv6,
updated_at=excluded.updated_at
""",
(hostname, effective_ipv4, effective_ipv6, now),
(result.hostname, result.ipv4, result.ipv6, now),
)
return UpdateResult(
hostname=hostname,
ipv4=effective_ipv4,
ipv6=effective_ipv6,
changed=changed,
update_ipv4=update_ipv4,
update_ipv6=update_ipv6,
)
return result
def commit_update_result(self, result: UpdateResult) -> UpdateResult:
now = datetime.now(UTC).isoformat()
@@ -69,20 +121,16 @@ class RecordsEventsMixin(StoreConnectionMixin):
update_ipv4: bool = True,
update_ipv6: bool = True,
) -> UpdateResult:
update = RecordUpdateInput(hostname, ipv4, ipv6, update_ipv4, update_ipv6)
with self.connect() as conn:
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
current_ipv4 = existing["ipv4"] if existing else None
current_ipv6 = existing["ipv6"] if existing else None
effective_ipv4 = ipv4 if update_ipv4 else current_ipv4
effective_ipv6 = ipv6 if update_ipv6 else current_ipv6
changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6
return UpdateResult(
hostname=hostname,
ipv4=effective_ipv4,
ipv6=effective_ipv6,
changed=changed,
update_ipv4=update_ipv4,
update_ipv6=update_ipv6,
return _planned_record_update(
update,
current_ipv4=current_ipv4,
current_ipv6=current_ipv6,
existing=existing is not None,
)
def list_records(self) -> list[RecordRow]:
@@ -106,33 +154,27 @@ class RecordsEventsMixin(StoreConnectionMixin):
row = conn.execute("SELECT COUNT(*) FROM records").fetchone()
return int(row[0])
def log_update_event(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
status: str,
detail: str,
source_ip: str,
) -> None:
def log_update_event(self, event: UpdateEventInput | str, *legacy_values: object) -> None:
event = _update_event_input(event, legacy_values)
with self.connect() as conn:
conn.execute(
"""
INSERT INTO update_events(hostname, ipv4, ipv6, status, detail, source_ip, created_at)
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(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
status: str,
detail: str,
source_ip: str,
) -> None:
def log_update_rollup(self, event: UpdateEventInput | str, *legacy_values: object) -> None:
event = _update_event_input(event, legacy_values)
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.execute(
@@ -145,7 +187,16 @@ class RecordsEventsMixin(StoreConnectionMixin):
last_seen_at=excluded.last_seen_at,
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]:
+4 -1
View File
@@ -186,7 +186,10 @@ def _migrate_accounts_schema(conn: sqlite3.Connection) -> None:
)
""")
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
FROM accounts
""")
+12 -12
View File
@@ -1,22 +1,22 @@
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
update_slug: str
management_slug: str
dashboard_password_hash: str | None
created_at: str
accepted_at: str | None
disabled: int
ipv4: str | None
ipv6: str | None
updated_at: str | None
management_slug: NotRequired[str]
dashboard_password_hash: NotRequired[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
ipv4: str | None
ipv6: str | None
@@ -33,9 +33,9 @@ class CleanupRunRow(TypedDict):
class ScheduledTaskRow(TypedDict, total=False):
name: str
interval_seconds: int
next_run_at: str
name: NotRequired[str]
interval_seconds: NotRequired[int]
next_run_at: NotRequired[str]
lease_owner: str | None
lease_expires_at: str | None
last_started_at: str | None
+110 -39
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from ipaddress import ip_address
from .html_utils import SafeHtml, attr, escape, trusted
@@ -28,32 +29,77 @@ def _event_label(status: object) -> str:
return _EVENT_LABELS.get(value, value.replace("_", " "))
@dataclass(frozen=True, slots=True)
class _CopyRowParts:
label: str
value: str
safe_label: str
safe_value: str
attr_label: str
attr_value: str
is_secret: bool
should_mask: bool
sensitivity_badge: str
@property
def row_class(self) -> str:
return "copy-field copy-field-secret" if self.is_secret else "copy-field copy-field-public"
@property
def value_field(self) -> str:
if self.should_mask:
return (
f'<input class="copy-pill-value copy-value" type="password" value="{self.attr_value}" '
"readonly data-secret-input>"
)
return f'<code class="copy-pill-value copy-value">{self.safe_value}</code>'
@property
def reveal_button(self) -> str:
if not self.should_mask:
return ""
return (
f'<button type="button" class="btn btn-outline-secondary rounded-pill copy-reveal-button" '
f'data-secret-toggle aria-label="Show {self.attr_label}">View</button>'
)
@classmethod
def from_values(
cls, label: str, value: str, sensitivity: str | None = None, *, mask_secret: bool = True
) -> _CopyRowParts:
is_secret = bool(sensitivity and sensitivity.lower() == "secret")
return cls(
label=label,
value=value,
safe_label=escape(label),
safe_value=escape(value),
attr_label=attr(label),
attr_value=attr(value),
is_secret=is_secret,
should_mask=is_secret and mask_secret,
sensitivity_badge=_copy_row_sensitivity_badge(sensitivity, is_secret),
)
def _copy_row_sensitivity_badge(sensitivity: str | None, is_secret: bool) -> str:
if not sensitivity:
return ""
badge_kind = "secret" if is_secret else "public"
return f'<span class="ui-badge ui-badge-{badge_kind}">{escape(sensitivity)}</span>'
def _copy_row(label: str, value: str, sensitivity: str | None = None, *, mask_secret: bool = True) -> SafeHtml:
safe_label = escape(label)
safe_value = escape(value)
attr_label = attr(label)
attr_value = attr(value)
is_secret = bool(sensitivity and sensitivity.lower() == "secret")
should_mask = is_secret and mask_secret
value_field = (
f'<input class="copy-pill-value copy-value" type="password" value="{attr_value}" readonly data-secret-input>'
if should_mask
else f'<code class="copy-pill-value copy-value">{safe_value}</code>'
)
reveal_button = (
f'<button type="button" class="btn btn-outline-secondary rounded-pill copy-reveal-button" data-secret-toggle aria-label="Show {attr_label}">View</button>'
if should_mask
else ""
)
row_class = "copy-field copy-field-secret" if is_secret else "copy-field copy-field-public"
parts = _CopyRowParts.from_values(label, value, sensitivity, mask_secret=mask_secret)
return trusted(f"""
<div class="{row_class}">
<div class="{parts.row_class}">
<div class="copy-pill">
<span class="copy-pill-label">{safe_label}</span>
{value_field}
{reveal_button}
<span class="copy-pill-label">{parts.safe_label}</span>
{parts.sensitivity_badge}
{parts.value_field}
{parts.reveal_button}
</div>
<button type="button" class="btn btn-outline-secondary rounded-pill copy-button" data-copy="{attr_value}" aria-label="Copy {attr_label}">
<button type="button" class="btn btn-outline-secondary rounded-pill copy-button"
data-copy="{parts.attr_value}" aria-label="Copy {parts.attr_label}">
{_svg_icon("clipboard")}<span data-copy-label>Copy</span>{_svg_icon("check", "check-icon")}
</button>
</div>
@@ -66,16 +112,22 @@ def _copy_button(label: str, value: str, *, style: str = "outline-primary") -> S
safe_style = attr(style)
return trusted(
f'<button type="button" class="btn btn-{safe_style} rounded-pill copy-button" data-copy="{safe_value}" '
f'aria-label="Copy {safe_label}">{_svg_icon("clipboard")}<span data-copy-label>{safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
f'aria-label="Copy {safe_label}">{_svg_icon("clipboard")}'
f'<span data-copy-label>{safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
)
def _copy_icon_button(label: str, value: str) -> SafeHtml:
safe_label = escape(label)
safe_value = attr(value)
button_attrs = (
f'type="button" class="copy-icon-button" data-copy="{safe_value}" '
f'aria-label="Copy {safe_label}" title="Copy"'
)
return trusted(
f'<button type="button" class="copy-icon-button" data-copy="{safe_value}" aria-label="Copy {safe_label}" title="Copy">'
f'{_svg_icon("clipboard")}<span class="visually-hidden" data-copy-label>Copy {safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
f"<button {button_attrs}>"
f'{_svg_icon("clipboard")}<span class="visually-hidden" data-copy-label>Copy {safe_label}</span>'
f'{_svg_icon("check", "check-icon")}</button>'
)
@@ -83,9 +135,14 @@ def _copy_menu(label: str, items: list[tuple[str, str]]) -> SafeHtml:
safe_label = escape(label)
buttons = []
for item_label, item_value in items:
button_attrs = (
f'type="button" class="copy-menu-item" data-copy="{attr(item_value)}" '
f'aria-label="Copy {attr(item_label)}"'
)
buttons.append(
f'<button type="button" class="copy-menu-item" data-copy="{attr(item_value)}" aria-label="Copy {attr(item_label)}">'
f'{_svg_icon("clipboard")}<span data-copy-label>{escape(item_label)}</span>{_svg_icon("check", "check-icon")}</button>'
f"<button {button_attrs}>"
f'{_svg_icon("clipboard")}<span data-copy-label>{escape(item_label)}</span>'
f'{_svg_icon("check", "check-icon")}</button>'
)
return trusted(f"""
<details class="copy-menu">
@@ -106,7 +163,7 @@ def _code_window(
code_lines = "\n".join(f"<code>{escape(line)}</code>" for line in commands.splitlines())
copy_value = copy_value if copy_value is not None else _code_copy_value(commands)
return trusted(f"""
<div class="terminal-card code-window{safe_class}">
<div class="terminal-card code-window{safe_class}" data-slot="code-window">
{_copy_icon_button(copy_label, copy_value)}
{code_lines}
</div>
@@ -115,10 +172,14 @@ def _code_window(
def _code_copy_value(commands: str) -> str:
lines = commands.splitlines()
prompt_lines = [line[2:] for line in lines if line.startswith("$ ")]
prompt_lines = [_code_copy_line(line) for line in lines if line.startswith("$ ")]
if prompt_lines:
return "\n".join(prompt_lines)
return "\n".join(line[2:] if line.startswith("$ ") else line for line in lines)
return "\n".join(_code_copy_line(line) for line in lines)
def _code_copy_line(line: str) -> str:
return line[2:] if line.startswith("$ ") else line
def _example_update_ips(request_ip: str | None = None) -> tuple[str, str]:
@@ -164,12 +225,17 @@ def _known_request_ip(request_ip: str | None = None) -> tuple[int | None, str]:
def _manual_update_panel(commands: str) -> SafeHtml:
return trusted(f"""
<div class="card card-body gap-3 mt-4">
<div>
<div class="card card-body gap-3 mt-4" data-slot="card">
<div data-slot="card-header">
<h3>Manual updates</h3>
<p class="section-copy">FRITZ!Box fills in the IP placeholders automatically. Scripts can use curl. If no IP is sent, KarlDNS uses the request IP.</p>
<p class="section-copy">
FRITZ!Box fills in the IP placeholders automatically. Scripts can use curl.
If no IP is sent, KarlDNS uses the request IP.
</p>
</div>
<div data-slot="card-content">
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
</div>
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
</div>
""")
@@ -194,10 +260,14 @@ def _secret_notice(*, include_registration: bool = False) -> SafeHtml:
"Update URL, register URL, and dashboard link" if include_registration else "Update URL and dashboard link"
)
return trusted(f"""
<div class="alert alert-info my-3 py-2 small" role="note">
<strong>Public:</strong> Hostname and CNAME target can be shared.
<strong>Secret:</strong> {escape(secret_items)} can update or manage this hostname.
Secrets are hidden on screen. Copy still copies the real value.
<div class="alert alert-info ui-alert my-3 py-2 small" role="note" data-slot="alert">
<span class="ui-badge ui-badge-public">Public:</span>
<span>Hostname and CNAME target can be shared.</span>
<span class="ui-badge ui-badge-secret">Secret:</span>
<span>
{escape(secret_items)} can update or manage this hostname. Secrets are hidden on screen.
Copy still copies the real value.
</span>
</div>
""")
@@ -206,7 +276,8 @@ def _message_band(message: str | None) -> SafeHtml:
if not message:
return trusted("")
return trusted(f"""
<div class="alert alert-warning mb-0 rounded-0 border-start-0 border-end-0" role="status">
<div class="alert alert-warning ui-alert mb-0 rounded-0 border-start-0 border-end-0"
role="status" data-slot="alert">
<div class="container">{escape(message)}</div>
</div>
""")
+2 -1
View File
@@ -22,7 +22,8 @@ def _svg_icon(name: str, css_class: str = "copy-icon") -> SafeHtml:
safe_class = attr(f"lucide-icon {css_class}")
return trusted(
f'<svg class="{safe_class}" aria-hidden="true" focusable="false" width="16" height="16" viewBox="0 0 24 24" '
f'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{path}</svg>'
f'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" '
f'stroke-linejoin="round">{path}</svg>'
)
+107 -65
View File
@@ -1,10 +1,27 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from .html_utils import SafeHtml, attr, escape, trusted
from .icons import _svg_icon
DEFAULT_DESCRIPTION = (
"KarlDNS is a small self-hosted Dynamic DNS service for FRITZ!Box routers, " "Linux cron jobs, and home networks."
)
@dataclass(frozen=True)
class PageOptions:
extra_styles: SafeHtml = field(default_factory=lambda: trusted(""))
extra_scripts: SafeHtml = field(default_factory=lambda: trusted(""))
description: str = DEFAULT_DESCRIPTION
preload_logo: bool = False
canonical_url: str | None = None
image_url: str | None = None
image_alt: str | None = None
structured_data: dict[str, object] | None = None
def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtml:
account_html = ""
@@ -22,8 +39,11 @@ def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtm
<a class="skip-link" href="#main">Skip to content</a>
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
<div class="container">
<a class="navbar-brand brand" href="/"><img src="/logo-nav.webp?v=3" alt="" width="28" height="28" decoding="async">KarlDNS</a>
<div class="navbar-nav site-nav-actions ms-auto flex-row align-items-center justify-content-end gap-2 gap-sm-3">
<a class="navbar-brand brand" href="/">
<img src="/logo-nav.webp?v=3" alt="" width="28" height="28" decoding="async">KarlDNS
</a>
<div class="navbar-nav site-nav-actions ms-auto flex-row align-items-center justify-content-end
gap-2 gap-sm-3">
{links}
{account_html}
{theme_toggle}
@@ -68,60 +88,11 @@ def _site_footer() -> SafeHtml:
def _page(
title: str,
body: SafeHtml,
extra_styles: SafeHtml | None = None,
extra_scripts: SafeHtml | None = None,
description: str = "KarlDNS is a small self-hosted Dynamic DNS service for FRITZ!Box routers, Linux cron jobs, and home networks.",
preload_logo: bool = False,
canonical_url: str | None = None,
image_url: str | None = None,
image_alt: str | None = None,
structured_data: dict[str, object] | None = None,
options: PageOptions | None = None,
) -> str:
if not isinstance(body, SafeHtml):
raise TypeError("body must be a SafeHtml fragment built by a template helper")
extra_styles_html = extra_styles or trusted("")
extra_scripts_html = extra_scripts or trusted("")
preload_logo_html = (
trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">')
if preload_logo
else trusted("")
)
canonical_html = trusted(f'<link rel="canonical" href="{attr(canonical_url)}">') if canonical_url else trusted("")
social_url = canonical_url or ""
social_image = image_url or ""
social_html = trusted("")
if social_url:
social_card = "summary_large_image" if social_image else "summary"
social_image_alt = image_alt or title
social_image_html = (
f"""
<meta property="og:image" content="{attr(social_image)}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{attr(social_image_alt)}">
<meta name="twitter:image" content="{attr(social_image)}">
<meta name="twitter:image:alt" content="{attr(social_image_alt)}">
"""
if social_image
else ""
)
social_html = trusted(f"""
<meta property="og:type" content="website">
<meta property="og:title" content="{attr(title)}">
<meta property="og:description" content="{attr(description)}">
<meta property="og:url" content="{attr(social_url)}">
{social_image_html}
<meta name="twitter:card" content="{attr(social_card)}">
<meta name="twitter:title" content="{attr(title)}">
<meta name="twitter:description" content="{attr(description)}">
""")
structured_data_html = trusted("")
if structured_data:
structured_data_html = trusted(
'<script type="application/ld+json">'
+ escape(json.dumps(structured_data, ensure_ascii=False, separators=(",", ":")))
+ "</script>"
)
options = options or PageOptions()
return f"""
<!doctype html>
<html lang="en">
@@ -129,25 +100,96 @@ def _page(
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
<meta name="description" content="{attr(description)}">
{canonical_html}
{social_html}
<meta name="description" content="{attr(options.description)}">
{_canonical_tag(options.canonical_url)}
{_social_meta_tags(
title=title,
description=options.description,
canonical_url=options.canonical_url,
image_url=options.image_url,
image_alt=options.image_alt,
)}
<title>{escape(title)}</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
{preload_logo_html}
<script src="/theme.js?v=2"></script>
<script src="/clipboard.js?v=3" defer></script>
<script src="/visitor-ip.js?v=1" defer></script>
{extra_scripts_html}
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
<link href="/app.css?v=13" rel="stylesheet">
{extra_styles_html}
{structured_data_html}
{_logo_preload_tag(options.preload_logo)}
{_base_script_tags()}
{options.extra_scripts}
{_base_style_tags()}
{options.extra_styles}
{_structured_data_tag(options.structured_data)}
</head>
<body>{body}{_site_footer()}</body>
</html>
"""
def _canonical_tag(canonical_url: str | None) -> SafeHtml:
return trusted(f'<link rel="canonical" href="{attr(canonical_url)}">') if canonical_url else trusted("")
def _logo_preload_tag(preload_logo: bool) -> SafeHtml:
if not preload_logo:
return trusted("")
return trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">')
def _base_script_tags() -> SafeHtml:
return trusted("""
<script src="/theme.js?v=2"></script>
<script src="/clipboard.js?v=3" defer></script>
<script src="/visitor-ip.js?v=1" defer></script>
""")
def _base_style_tags() -> SafeHtml:
return trusted("""
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
<link href="/app.css?v=14" rel="stylesheet">
""")
def _social_meta_tags(
*,
title: str,
description: str,
canonical_url: str | None,
image_url: str | None,
image_alt: str | None,
) -> SafeHtml:
if not canonical_url:
return trusted("")
social_card = "summary_large_image" if image_url else "summary"
return trusted(f"""
<meta property="og:type" content="website">
<meta property="og:title" content="{attr(title)}">
<meta property="og:description" content="{attr(description)}">
<meta property="og:url" content="{attr(canonical_url)}">
{_social_image_tags(image_url, image_alt or title)}
<meta name="twitter:card" content="{attr(social_card)}">
<meta name="twitter:title" content="{attr(title)}">
<meta name="twitter:description" content="{attr(description)}">
""")
def _social_image_tags(image_url: str | None, image_alt: str) -> SafeHtml:
if not image_url:
return trusted("")
return trusted(f"""
<meta property="og:image" content="{attr(image_url)}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{attr(image_alt)}">
<meta name="twitter:image" content="{attr(image_url)}">
<meta name="twitter:image:alt" content="{attr(image_alt)}">
""")
def _structured_data_tag(structured_data: dict[str, object] | None) -> SafeHtml:
if not structured_data:
return trusted("")
payload = json.dumps(structured_data, ensure_ascii=False, separators=(",", ":"))
return trusted('<script type="application/ld+json">' + escape(payload) + "</script>")
+1 -280
View File
@@ -1,282 +1,3 @@
from __future__ import annotations
THEME_JS = """
(() => {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const storageKey = "karldns-theme";
const systemTheme = () => (media.matches ? "dark" : "light");
const storedTheme = () => {
try {
return window.localStorage.getItem(storageKey);
} catch (_) {
return null;
}
};
const setStoredTheme = (theme) => {
try {
window.localStorage.setItem(storageKey, theme);
} catch (_) {}
};
const syncToggle = (theme) => {
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
const nextTheme = theme === "dark" ? "light" : "dark";
button.dataset.themeNext = nextTheme;
button.setAttribute("aria-label", `Switch to ${nextTheme.toLowerCase()} theme`);
});
};
const applyTheme = (requestedTheme = storedTheme() || systemTheme()) => {
const theme = requestedTheme === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-bs-theme", theme);
syncToggle(theme);
return theme;
};
applyTheme();
media.addEventListener("change", () => {
if (!storedTheme()) applyTheme();
});
document.addEventListener("DOMContentLoaded", () => syncToggle(document.documentElement.getAttribute("data-bs-theme") || "light"));
document.addEventListener("click", (event) => {
const toggle = event.target.closest("[data-theme-toggle]");
if (!toggle) return;
const currentTheme = document.documentElement.getAttribute("data-bs-theme") || systemTheme();
const nextTheme = currentTheme === "dark" ? "light" : "dark";
setStoredTheme(nextTheme);
applyTheme(nextTheme);
});
})();
""".strip()
CLIPBOARD_JS = """
document.addEventListener("click", async (event) => {
document.querySelectorAll("details.copy-menu[open]").forEach((menu) => {
if (!menu.contains(event.target)) menu.removeAttribute("open");
});
const toggle = event.target.closest("[data-secret-toggle]");
if (toggle) {
const group = toggle.closest(".copy-field");
const field = group ? group.querySelector("[data-secret-input], [data-secret-value]") : null;
if (!field) return;
if (field.matches("[data-secret-input]")) {
const masked = field.type === "password";
field.type = masked ? "text" : "password";
toggle.textContent = masked ? "Hide" : "View";
toggle.setAttribute("aria-label", masked ? "Hide secret value" : "Show secret value");
} else {
const masked = field.dataset.secretMasked !== "false";
field.dataset.secretMasked = masked ? "false" : "true";
field.textContent = masked ? (field.dataset.secretValue || "") : "********";
toggle.textContent = masked ? "Hide" : "View";
toggle.setAttribute("aria-label", masked ? "Hide secret value" : "Show secret value");
}
return;
}
const button = event.target.closest("[data-copy]");
if (!button) return;
try {
await navigator.clipboard.writeText(button.dataset.copy || "");
const label = button.querySelector("[data-copy-label]");
const oldLabel = label ? label.textContent : "";
button.classList.add("is-copied");
if (label) label.textContent = "Copied";
const menu = button.closest("details.copy-menu");
if (menu) {
setTimeout(() => {
menu.removeAttribute("open");
}, 250);
}
setTimeout(() => {
button.classList.remove("is-copied");
if (label) label.textContent = oldLabel;
}, 1200);
} catch (_) {}
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
document.querySelectorAll("details.copy-menu[open]").forEach((menu) => {
menu.removeAttribute("open");
});
});
""".strip()
ADMIN_TABLES_JS = """
document.querySelectorAll("[data-admin-table]").forEach((tableShell) => {
const table = tableShell.querySelector("table");
const tbody = tableShell.querySelector("tbody");
const search = tableShell.querySelector("[data-table-search]");
const pageSize = tableShell.querySelector("[data-table-page-size]");
const count = tableShell.querySelector("[data-table-count]");
const pagination = tableShell.querySelector("[data-table-pagination]");
if (!table || !tbody || !search || !pageSize || !count || !pagination) return;
const headerCount = table.querySelectorAll("thead th").length || 1;
const originalRows = Array.from(tbody.querySelectorAll("tr"));
const dataRows = originalRows.filter((row) => !row.classList.contains("empty-row"));
let currentPage = 1;
const emptyRow = (message) => {
const row = document.createElement("tr");
row.className = "empty-row";
const cell = document.createElement("td");
cell.className = "empty";
cell.colSpan = headerCount;
cell.textContent = message;
row.append(cell);
return row;
};
const visibleRows = () => {
const query = search.value.trim().toLowerCase();
if (!query) return dataRows;
return dataRows.filter((row) => row.textContent.toLowerCase().includes(query));
};
const renderButton = (label, page, disabled = false, active = false) => {
const item = document.createElement("li");
item.className = `page-item${disabled ? " disabled" : ""}${active ? " active" : ""}`;
const button = document.createElement("button");
button.type = "button";
button.className = "page-link";
button.textContent = label;
button.disabled = disabled;
button.addEventListener("click", () => {
currentPage = page;
render();
});
item.append(button);
return item;
};
const renderPagination = (totalPages) => {
pagination.replaceChildren();
if (totalPages <= 1) return;
pagination.append(renderButton("Previous", Math.max(1, currentPage - 1), currentPage === 1));
for (let page = 1; page <= totalPages; page += 1) {
if (totalPages > 7 && page !== 1 && page !== totalPages && Math.abs(page - currentPage) > 1) {
if (page === 2 || page === totalPages - 1) pagination.append(renderButton("...", currentPage, true));
continue;
}
pagination.append(renderButton(String(page), page, false, page === currentPage));
}
pagination.append(renderButton("Next", Math.min(totalPages, currentPage + 1), currentPage === totalPages));
};
const render = () => {
const rows = visibleRows();
const size = Number.parseInt(pageSize.value, 10) || 25;
const totalPages = Math.max(1, Math.ceil(rows.length / size));
currentPage = Math.min(currentPage, totalPages);
const start = (currentPage - 1) * size;
const pageRows = rows.slice(start, start + size);
tbody.replaceChildren(...(pageRows.length ? pageRows : [emptyRow(dataRows.length ? "No matching rows." : "No rows yet.")]));
const shownStart = rows.length ? start + 1 : 0;
const shownEnd = rows.length ? Math.min(start + size, rows.length) : 0;
count.textContent = `${shownStart}-${shownEnd} of ${rows.length}`;
renderPagination(totalPages);
};
search.addEventListener("input", () => {
currentPage = 1;
render();
});
pageSize.addEventListener("change", () => {
currentPage = 1;
render();
});
render();
});
""".strip()
VISITOR_IP_JS = """
(() => {
const ipv4Placeholder = "203.0.113.10";
const ipv6Placeholder = "2001:db8::10";
const needsIpv4 = () => document.body.textContent.includes(ipv4Placeholder) || document.querySelector(`[data-copy*="${ipv4Placeholder}"]`);
const needsIpv6 = () => document.body.textContent.includes(ipv6Placeholder) || document.querySelector(`[data-copy*="${ipv6Placeholder}"]`);
const fetchIp = async (url) => {
try {
const response = await fetch(url, { cache: "no-store", mode: "cors" });
if (!response.ok) return "";
const payload = await response.json();
return typeof payload.ip === "string" ? payload.ip : "";
} catch (_) {
return "";
}
};
const replaceInTextNodes = (search, replacement) => {
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) {
if (walker.currentNode.nodeValue.includes(search)) nodes.push(walker.currentNode);
}
nodes.forEach((node) => {
node.nodeValue = node.nodeValue.split(search).join(replacement);
});
};
const replaceInCopyValues = (search, replacement) => {
document.querySelectorAll("[data-copy]").forEach((element) => {
const value = element.dataset.copy || "";
if (value.includes(search)) element.dataset.copy = value.split(search).join(replacement);
});
};
const replaceEverywhere = (search, replacement) => {
if (!replacement || replacement === search) return;
replaceInTextNodes(search, replacement);
replaceInCopyValues(search, replacement);
};
const updateCopyCommand = (button, ipv6Command) => {
const value = button.dataset.copy || "";
if (!value || value.includes("?myipv6=")) return;
const lines = value.split("\\n");
const output = [];
let inserted = false;
lines.forEach((line) => {
output.push(line);
if (!inserted && line.includes("/api/v1/router/update/") && line.includes("?myip=")) {
output.push(ipv6Command);
inserted = true;
}
});
if (inserted) button.dataset.copy = output.join("\\n");
};
const insertIpv6Examples = (ip) => {
if (!ip) return;
document.querySelectorAll(".terminal-card.manual-code").forEach((card) => {
if (card.textContent.includes("?myipv6=")) return;
const lines = [...card.querySelectorAll("code")];
const ipv4Line = lines.find((line) => line.textContent.includes("/api/v1/router/update/") && line.textContent.includes("?myip="));
if (!ipv4Line) return;
const base = ipv4Line.textContent.split("?myip=", 1)[0].replace(/'$/, "");
const ipv6Command = `${base}?myipv6=${ip}'`;
const code = document.createElement("code");
code.textContent = ipv6Command;
ipv4Line.after(code);
const copyButton = card.querySelector("[data-copy]");
if (copyButton) updateCopyCommand(copyButton, ipv6Command);
});
};
document.addEventListener("DOMContentLoaded", async () => {
const tasks = [];
if (needsIpv4()) tasks.push(fetchIp("https://api4.ipify.org?format=json").then((ip) => replaceEverywhere(ipv4Placeholder, ip)));
if (needsIpv6() || document.querySelector(".terminal-card.manual-code")) {
tasks.push(fetchIp("https://api6.ipify.org?format=json").then((ip) => {
replaceEverywhere(ipv6Placeholder, ip);
insertIpv6Examples(ip);
}));
}
await Promise.allSettled(tasks);
});
})();
""".strip()
"""JavaScript is served from package assets by karldns.routers.assets."""
+1
View File
@@ -0,0 +1 @@
from __future__ import annotations
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
from ..components import _code_window
from ..html_utils import SafeHtml, escape, trusted
def _public_home_hero(*, suffix: str, demo_update_url: str) -> SafeHtml:
return trusted(f"""
<section class="hero-band">
<div class="container text-center d-grid justify-items-center gap-3">
<img class="hero-logo" src="/logo.webp?v=3" alt="" width="80" height="80"
decoding="async" fetchpriority="high">
<h1>Dynamic DNS for home routers.</h1>
<div class="install-pill mx-auto" data-ui="hero-command">
<code>{escape(demo_update_url)}</code>
</div>
<p class="lead">
KarlDNS gives you a generated DynDNS hostname under {escape(suffix)}.
Add the private update URL to your router, or use it from a script.
</p>
<div class="d-flex gap-2 flex-wrap justify-content-center mt-2">
<form method="post" action="/" class="hero-action-form"
aria-label="Create a generated DynDNS hostname">
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
</form>
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">Manual updates</a>
</div>
</div>
</section>
""")
def _setup_flow_section(*, create_demo: str, create_demo_command: str) -> SafeHtml:
return trusted(f"""
<section class="section section-compact">
<div class="container narrow text-center">
<p class="eyebrow">How it works</p>
<h2>No account needed.</h2>
<p class="section-copy mx-auto">Create a hostname, save the private values, register it, done.</p>
<ol class="flow-steps" aria-label="KarlDNS setup flow">
<li><strong>Create</strong><span>Random hostname and private links.</span></li>
<li><strong>Save</strong><span>Copy the secrets while they are shown.</span></li>
<li><strong>Register</strong><span>Activate the hostname when you are ready.</span></li>
<li><strong>Update</strong><span>Router, cron, or CNAME can use it.</span></li>
</ol>
{_code_window(
create_demo,
copy_label="update example",
class_name="mt-4",
copy_value=create_demo_command,
)}
</div>
</section>
""")
def _manual_updates_section(*, manual_demo: str) -> SafeHtml:
return trusted(f"""
<section class="section" id="manual-updates">
<div class="container narrow text-center">
<h2>Update from a router or script.</h2>
<p class="lead mx-auto mt-3">
Works with FRITZ!Box, curl, cron, Home Assistant, OpenWrt, pfSense,
OPNsense, UniFi, MikroTik, and custom tools.
</p>
{_code_window(manual_demo, copy_label="manual update examples", class_name="manual-code text-start mt-4")}
</div>
</section>
""")
+15 -9
View File
@@ -2,10 +2,12 @@ from __future__ import annotations
import logging
import time
from collections.abc import Awaitable, Callable
from fastapi import FastAPI, Request
from fastapi.responses import PlainTextResponse
from starlette.concurrency import run_in_threadpool
from starlette.responses import Response
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from ..security import client_ip, is_rate_limited_path, rate_limit_bucket
@@ -13,6 +15,12 @@ from ..settings import DdnsSettings
from ..store import DdnsStore
ACCESS_LOGGER = logging.getLogger("karldns.access")
SECRET_RESPONSE_PREFIXES = (
"/m/",
"/api/v1/hostnames",
"/api/v1/router/",
"/api/v1/admin/",
)
class RequestBodyTooLargeError(Exception):
@@ -49,7 +57,7 @@ class RequestBodyLimitMiddleware:
def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None:
@app.middleware("http")
async def security_middleware(request: Request, call_next):
async def security_middleware(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
started_at = time.perf_counter()
try:
content_length = int(request.headers.get("content-length", "0") or "0")
@@ -102,14 +110,12 @@ def is_admin_path(path: str, settings: DdnsSettings) -> bool:
def is_secret_response(request: Request) -> bool:
path = request.url.path
settings = request.app.state.settings
if is_admin_path(path, settings) or path.startswith("/m/"):
return True
if path.startswith(("/api/v1/hostnames", "/api/v1/router/")):
return True
if path.startswith("/api/v1/admin/"):
return True
return request.method == "POST" and path == "/"
return (
is_admin_path(path, request.app.state.settings)
or path.startswith(SECRET_RESPONSE_PREFIXES)
or request.method == "POST"
and path == "/"
)
def is_noindex_response(request: Request) -> bool:
+1 -1718
View File
File diff suppressed because it is too large. Load diff
+270 -114
View File
@@ -1,14 +1,15 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from urllib.parse import urlencode
from ..security import admin_csrf_token
from ..services.ddns import DdnsService
from ..settings import DdnsSettings
from ..store import DdnsStore
from .html_utils import SafeHtml, attr, escape, trusted
from .layout import _page, _top_nav
from .layout import PageOptions, _page, _top_nav
from .templates_admin_tables import (
_account_row,
_admin_table_pagination,
@@ -17,6 +18,52 @@ from .templates_admin_tables import (
)
@dataclass(frozen=True)
class _CleanupSummary:
last_run: object
next_run: object
status: object
last_error: object
interval_seconds: object
accounts_removed: object
@dataclass(frozen=True)
class AdminPageContext:
accounts: Sequence[Mapping[str, object]]
cleanup_runs: Sequence[Mapping[str, object]]
scheduled_tasks: Sequence[Mapping[str, object]]
update_events: Sequence[Mapping[str, object]]
service_metrics: dict[str, float]
table_counts: dict[str, int]
table_offsets: dict[str, int]
table_limit: int
notice_html: SafeHtml | None
def _admin_page_context(
store: DdnsStore,
table_offsets: dict[str, int],
table_limit: int,
notice_html: SafeHtml | None,
) -> AdminPageContext:
return AdminPageContext(
accounts=store.list_accounts(limit=table_limit, offset=table_offsets.get("accounts", 0)),
cleanup_runs=store.list_cleanup_runs(limit=table_limit, offset=table_offsets.get("cleanup_runs", 0)),
scheduled_tasks=store.list_scheduled_tasks(),
update_events=store.list_update_events(table_limit, offset=table_offsets.get("update_events", 0)),
service_metrics=store.get_service_metrics(),
table_counts={
"accounts": store.count_accounts(),
"cleanup_runs": store.count_cleanup_runs(),
"update_events": store.count_update_events(),
},
table_offsets=table_offsets,
table_limit=table_limit,
notice_html=notice_html,
)
def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
return trusted(f"""
<section class="section success-section">
@@ -33,116 +80,193 @@ def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
def _render_admin_page(
service: DdnsService,
settings: DdnsSettings,
accounts: Sequence[Mapping[str, object]],
cleanup_runs: Sequence[Mapping[str, object]],
scheduled_tasks: Sequence[Mapping[str, object]],
update_events: Sequence[Mapping[str, object]],
service_metrics: dict[str, float],
table_counts: dict[str, int],
table_offsets: dict[str, int],
table_limit: int,
notice_html: SafeHtml | None,
context: AdminPageContext,
) -> str:
csrf = attr(admin_csrf_token(settings))
admin_path = settings.admin_path
safe_admin_path = attr(admin_path)
rows = "\n".join(_account_row(account, csrf=csrf, admin_path=admin_path) for account in accounts) or """
<tr class="empty-row"><td colspan="7" class="empty">No hostnames yet.</td></tr>
"""
event_rows = "\n".join(_event_row(event) for event in update_events) or """
<tr class="empty-row"><td colspan="7" class="empty">No update events yet.</td></tr>
"""
accounts_count = escape(table_counts.get("accounts", len(accounts)))
event_count = escape(table_counts.get("update_events", len(update_events)))
metrics_html = _metrics_panel(service_metrics)
rows = _account_rows(context.accounts, csrf=csrf, admin_path=admin_path)
event_rows = _event_rows(context.update_events)
accounts_count = escape(context.table_counts.get("accounts", len(context.accounts)))
event_count = escape(context.table_counts.get("update_events", len(context.update_events)))
metrics_html = _metrics_panel(context.service_metrics)
account_pager = _server_page_links(
admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, table_counts.get("accounts", 0)
admin_path,
"accounts_offset",
context.table_offsets.get("accounts", 0),
context.table_limit,
context.table_counts.get("accounts", 0),
)
event_pager = _server_page_links(
admin_path,
"update_events_offset",
table_offsets.get("update_events", 0),
table_limit,
table_counts.get("update_events", 0),
context.table_offsets.get("update_events", 0),
context.table_limit,
context.table_counts.get("update_events", 0),
)
cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf)
totals_html = _totals_strip(table_counts, service_metrics)
cleanup_panel = _cleanup_panel(settings, context.cleanup_runs, context.scheduled_tasks, safe_admin_path, csrf)
totals_html = _totals_strip(context.table_counts, context.service_metrics)
title_section = _admin_title_section(totals_html)
status_section = _admin_status_section(cleanup_panel, metrics_html)
hostnames_section = _admin_hostnames_section(context, rows, accounts_count, account_pager)
events_section = _admin_events_section(context, event_rows, event_count, event_pager)
return _page(
"KarlDNS Admin",
trusted(f"""
<main id="main">
{_top_nav("Operator")}
<section class="admin-console">
<div class="container">
<div class="admin-page-title">
<div>
<p class="eyebrow">KarlDNS</p>
<h1>Operator console</h1>
</div>
{totals_html}
</div>
</div>
</section>
{notice_html or ""}
<section class="admin-block">
<div class="container">
{cleanup_panel}
{metrics_html}
</div>
</section>
<section class="admin-block">
<div class="container">
<div class="admin-section-heading">
<div>
<h2>Hostnames</h2>
<p class="section-copy mb-0">{escape(len(accounts))} of {accounts_count} shown.</p>
</div>
</div>
{_admin_table_toolbar("hostnames", "Search hostnames")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead>
<tr><th scope="col">Hostname</th><th scope="col">Status</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Updated</th><th scope="col">Links</th><th scope="col">Actions</th></tr>
</thead>
<tbody>{rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{account_pager}
</div>
</section>
<section class="admin-block">
<div class="container">
<div class="admin-section-heading">
<div>
<h2>Update events</h2>
<p class="section-copy mb-0">{escape(len(update_events))} of {event_count} shown. Unchanged checks are grouped.</p>
</div>
</div>
{_admin_table_toolbar("update-events", "Search update events")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th scope="col">Time</th><th scope="col">Hostname</th><th scope="col">Status</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Count</th><th scope="col">Detail</th></tr></thead>
<tbody>{event_rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{event_pager}
</div>
</section>
{title_section}
{context.notice_html or ""}
{status_section}
{hostnames_section}
{events_section}
</main>
"""),
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
extra_scripts=trusted('<script src="/admin-tables.js?v=2" defer></script>'),
PageOptions(
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
extra_scripts=trusted('<script src="/admin-tables.js?v=3" defer></script>'),
),
)
def _admin_title_section(totals_html: SafeHtml) -> SafeHtml:
return trusted(f"""
<section class="admin-console">
<div class="container">
<div class="admin-page-title">
<div>
<p class="eyebrow">KarlDNS</p>
<h1>Operator console</h1>
</div>
{totals_html}
</div>
</div>
</section>
""")
def _admin_status_section(cleanup_panel: SafeHtml, metrics_html: SafeHtml) -> SafeHtml:
return trusted(f"""
<section class="admin-block">
<div class="container">
{cleanup_panel}
{metrics_html}
</div>
</section>
""")
def _admin_hostnames_section(
context: AdminPageContext,
rows: SafeHtml,
accounts_count: str,
account_pager: SafeHtml,
) -> SafeHtml:
return trusted(f"""
<section class="admin-block">
<div class="container">
<div class="admin-section-heading">
<div>
<h2>Hostnames</h2>
<p class="section-copy mb-0">{escape(len(context.accounts))} of {accounts_count} shown.</p>
</div>
</div>
{_admin_table_toolbar("hostnames", "Filter visible hostnames")}
<div class="table-responsive border rounded admin-table-scroll" data-slot="card">
<table class="table mb-0">
<thead>
<tr>
<th scope="col">Hostname</th>
<th scope="col">Status</th>
<th scope="col">IPv4</th>
<th scope="col">IPv6</th>
<th scope="col">Updated</th>
<th scope="col">Links</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{account_pager}
</div>
</section>
""")
def _admin_events_section(
context: AdminPageContext,
event_rows: SafeHtml,
event_count: str,
event_pager: SafeHtml,
) -> SafeHtml:
return trusted(f"""
<section class="admin-block">
<div class="container">
<div class="admin-section-heading">
<div>
<h2>Update events</h2>
<p class="section-copy mb-0">
{escape(len(context.update_events))} of {event_count} shown. Unchanged checks are grouped.
</p>
</div>
</div>
{_admin_table_toolbar("update-events", "Filter visible update events")}
<div class="table-responsive border rounded admin-table-scroll" data-slot="card">
<table class="table mb-0">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Hostname</th>
<th scope="col">Status</th>
<th scope="col">IPv4</th>
<th scope="col">IPv6</th>
<th scope="col">Count</th>
<th scope="col">Detail</th>
</tr>
</thead>
<tbody>{event_rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{event_pager}
</div>
</section>
""")
def _account_rows(accounts: Sequence[Mapping[str, object]], *, csrf: str, admin_path: str) -> SafeHtml:
return trusted("\n".join(_account_row(account, csrf=csrf, admin_path=admin_path) for account in accounts) or """
<tr class="empty-row">
<td colspan="7" class="empty">
<div class="empty-state">
<div class="empty-state-title">No hostnames yet.</div>
<div class="empty-state-description">
Generated hostnames appear here after users create or register them.
</div>
</div>
</td>
</tr>
""")
def _event_rows(events: Sequence[Mapping[str, object]]) -> SafeHtml:
return trusted("\n".join(_event_row(event) for event in events) or """
<tr class="empty-row">
<td colspan="7" class="empty">
<div class="empty-state">
<div class="empty-state-title">No update events yet.</div>
<div class="empty-state-description">
Router update activity and DNS publish results will appear here.
</div>
</div>
</td>
</tr>
""")
def _totals_strip(table_counts: dict[str, int], metrics: dict[str, float]) -> SafeHtml:
items = [
("Hostnames", table_counts.get("accounts", 0)),
@@ -161,7 +285,7 @@ def _admin_notice(kind: str, message: str) -> SafeHtml:
alert_class = "alert-success" if kind == "success" else "alert-info"
return trusted(f"""
<div class="container">
<div class="alert {alert_class} admin-notice" role="status">{escape(message)}</div>
<div class="alert {alert_class} ui-alert admin-notice" role="status" data-slot="alert">{escape(message)}</div>
</div>
""")
@@ -180,8 +304,10 @@ def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: in
return trusted(f"""
<nav class="d-flex align-items-center justify-content-end gap-2 mt-2" aria-label="Server-side table pages">
<span class="small text-secondary">{escape(start)}-{escape(end)} of {escape(total)}</span>
<a class="btn btn-sm btn-light rounded-pill{previous_disabled}" href="{previous_href}" aria-disabled="{str(offset <= 0).lower()}">Previous</a>
<a class="btn btn-sm btn-light rounded-pill{next_disabled}" href="{next_href}" aria-disabled="{str(next_offset >= total).lower()}">Next</a>
<a class="btn btn-sm btn-light rounded-pill{previous_disabled}" href="{previous_href}"
aria-disabled="{str(offset <= 0).lower()}">Previous</a>
<a class="btn btn-sm btn-light rounded-pill{next_disabled}" href="{next_href}"
aria-disabled="{str(next_offset >= total).lower()}">Next</a>
</nav>
""")
@@ -193,37 +319,31 @@ def _cleanup_panel(
safe_admin_path: str,
csrf: str,
) -> SafeHtml:
task = next(
(item for item in scheduled_tasks if item.get("name") == "cleanup"),
scheduled_tasks[0] if scheduled_tasks else {},
summary = _cleanup_summary(
cleanup_runs=cleanup_runs,
scheduled_tasks=scheduled_tasks,
fallback_interval_seconds=settings.cleanup_interval_seconds,
)
last_run = task.get("last_finished_at") or (cleanup_runs[0].get("created_at") if cleanup_runs else None)
next_run = task.get("next_run_at")
status = task.get("last_status") or "pending"
last_error = task.get("last_error") or ""
interval = task.get("interval_seconds") or settings.cleanup_interval_seconds
last_cleanup = cleanup_runs[0] if cleanup_runs else {}
accounts_removed = last_cleanup.get("unused_accounts_deleted", 0)
error_html = (
trusted(f'<p class="small text-danger mb-0">Last error: {escape(last_error)}</p>')
if last_error
trusted(f'<p class="small text-danger mb-0">Last error: {escape(summary.last_error)}</p>')
if summary.last_error
else trusted("")
)
last_run_title = attr(str(last_run or "Never"))
next_run_title = attr(str(next_run or "Pending"))
last_run_display = escape(_format_timestamp(last_run, "Never"))
next_run_display = escape(_format_timestamp(next_run, "Pending"))
last_run_title = attr(str(summary.last_run or "Never"))
next_run_title = attr(str(summary.next_run or "Pending"))
last_run_display = escape(_format_timestamp(summary.last_run, "Never"))
next_run_display = escape(_format_timestamp(summary.next_run, "Pending"))
return trusted(f"""
<div class="admin-cleanup-bar">
<div class="admin-cleanup-title">
<h2>Cleanup</h2>
<span>Every {escape(_format_interval(interval))}</span>
<span>Every {escape(_format_interval(summary.interval_seconds))}</span>
</div>
<dl class="admin-cleanup-times">
<div><dt>Last run</dt><dd title="{last_run_title}">{last_run_display}</dd></div>
<div><dt>Next run</dt><dd title="{next_run_title}">{next_run_display}</dd></div>
<div><dt>Status</dt><dd>{escape(str(status))}</dd></div>
<div><dt>Removed</dt><dd>{escape(accounts_removed)}</dd></div>
<div><dt>Status</dt><dd>{escape(str(summary.status))}</dd></div>
<div><dt>Removed</dt><dd>{escape(summary.accounts_removed)}</dd></div>
</dl>
{error_html}
<form method="post" action="{safe_admin_path}/cleanup" aria-label="Run cleanup now">
@@ -234,6 +354,39 @@ def _cleanup_panel(
""")
def _cleanup_task(scheduled_tasks: Sequence[Mapping[str, object]]) -> Mapping[str, object]:
return next(
(item for item in scheduled_tasks if item.get("name") == "cleanup"),
scheduled_tasks[0] if scheduled_tasks else {},
)
def _cleanup_summary(
*,
cleanup_runs: Sequence[Mapping[str, object]],
scheduled_tasks: Sequence[Mapping[str, object]],
fallback_interval_seconds: int,
) -> _CleanupSummary:
task = _cleanup_task(scheduled_tasks)
last_cleanup = cleanup_runs[0] if cleanup_runs else {}
return _CleanupSummary(
last_run=_cleanup_last_run(task, last_cleanup),
next_run=task.get("next_run_at"),
status=task.get("last_status") or "pending",
last_error=task.get("last_error") or "",
interval_seconds=task.get("interval_seconds") or fallback_interval_seconds,
accounts_removed=_cleanup_accounts_removed(last_cleanup),
)
def _cleanup_last_run(task: Mapping[str, object], last_cleanup: Mapping[str, object]) -> object:
return task.get("last_finished_at") or last_cleanup.get("created_at")
def _cleanup_accounts_removed(last_cleanup: Mapping[str, object]) -> object:
return last_cleanup.get("unused_accounts_deleted", 0)
def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
samples = metrics.get("update_latency_samples_total", 0)
average_latency = metrics.get("update_latency_ms_total", 0) / samples if samples else 0
@@ -247,7 +400,10 @@ def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
("Last update time", f"{metrics.get('last_update_latency_ms', 0):.1f} ms"),
(
"Cleanup ok/errors",
f"{_metric_int(metrics.get('cleanup_success_total', 0))}/{_metric_int(metrics.get('cleanup_errors_total', 0))}",
(
f"{_metric_int(metrics.get('cleanup_success_total', 0))}/"
f"{_metric_int(metrics.get('cleanup_errors_total', 0))}"
),
),
]
cards = "\n".join(f"""
+51 -18
View File
@@ -1,12 +1,49 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime
from .components import _event_label
from .html_utils import attr, escape
@dataclass(frozen=True, slots=True)
class _AdminEventRow:
created_raw: str
hostname_raw: str
detail_raw: str
created: str
hostname: str
status: str
ipv4: str
ipv6: str
count: str
detail: str
def _admin_event_row_view(event: Mapping[str, object]) -> _AdminEventRow:
created_raw = _event_text(event, "created_at")
hostname_raw = _event_text(event, "hostname")
detail_raw = _event_text(event, "detail")
return _AdminEventRow(
created_raw=created_raw,
hostname_raw=hostname_raw,
detail_raw=detail_raw,
created=escape(_short_time(created_raw)),
hostname=escape(hostname_raw),
status=escape(_event_label(event.get("status"))),
ipv4=escape(_event_text(event, "ipv4")),
ipv6=escape(_event_text(event, "ipv6")),
count=escape(event.get("count") or 1),
detail=escape(detail_raw),
)
def _event_text(event: Mapping[str, object], key: str) -> str:
return str(event.get(key) or "-")
def _account_row(account: Mapping[str, object], csrf: str = "", admin_path: str = "/admin") -> str:
raw_hostname = str(account["hostname"])
hostname = escape(raw_hostname)
@@ -30,15 +67,18 @@ def _account_row(account: Mapping[str, object], csrf: str = "", admin_path: str
<form method="post" action="{safe_admin_path}/hostnames/rotate" class="admin-action-group">
<input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname_attr}">
<button name="action" value="update" class="btn btn-sm btn-light rounded-pill" title="Create a new update URL" aria-label="New update URL for {hostname_attr}">Update URL</button>
<button name="action" value="management" class="btn btn-sm btn-light rounded-pill" title="Create a new dashboard link" aria-label="New dashboard link for {hostname_attr}">Dashboard</button>
<button name="action" value="update" class="btn btn-sm btn-light rounded-pill"
title="Create a new update URL" aria-label="New update URL for {hostname_attr}">Update URL</button>
<button name="action" value="management" class="btn btn-sm btn-light rounded-pill"
title="Create a new dashboard link" aria-label="New dashboard link for {hostname_attr}">Dashboard</button>
</form>
</td>
<td class="cell-delete">
<form method="post" action="{safe_admin_path}/hostnames/delete">
<input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname_attr}">
<button class="btn btn-sm btn-outline-danger rounded-pill" title="Delete hostname" aria-label="Delete {hostname_attr}">Delete</button>
<button class="btn btn-sm btn-outline-danger rounded-pill" title="Delete hostname"
aria-label="Delete {hostname_attr}">Delete</button>
</form>
</td>
</tr>
@@ -53,7 +93,8 @@ def _admin_table_toolbar(table_id: str, search_label: str) -> str:
<div class="d-flex align-items-center justify-content-between gap-3 flex-column flex-md-row mb-3">
<label class="w-100 mb-0" for="{safe_id}-search">
<span class="visually-hidden">{safe_label}</span>
<input id="{safe_id}-search" class="form-control" type="search" placeholder="{safe_label}" data-table-search autocomplete="off">
<input id="{safe_id}-search" class="form-control" type="search" placeholder="{safe_label}"
data-table-search autocomplete="off">
</label>
<div class="d-flex align-items-center gap-2 flex-shrink-0">
<span class="small text-secondary text-nowrap" data-table-count>0-0 of 0</span>
@@ -79,21 +120,13 @@ def _admin_table_pagination() -> str:
def _event_row(event: Mapping[str, object]) -> str:
created_raw = str(event.get("created_at") or "-")
hostname_raw = str(event.get("hostname") or "-")
detail_raw = str(event.get("detail") or "-")
created = escape(_short_time(created_raw))
hostname = escape(hostname_raw)
status = escape(_event_label(event.get("status")))
ipv4 = escape(event.get("ipv4") or "-")
ipv6 = escape(event.get("ipv6") or "-")
count = escape(event.get("count") or 1)
detail = escape(detail_raw)
row = _admin_event_row_view(event)
return (
f'<tr><td class="cell-time" title="{attr(created_raw)}">{created}</td>'
f'<td class="cell-hostname" title="{attr(hostname_raw)}">{hostname}</td>'
f'<td class="cell-status">{status}</td><td class="cell-ip">{ipv4}</td><td class="cell-ip">{ipv6}</td>'
f'<td class="cell-count">{count}</td><td class="cell-detail" title="{attr(detail_raw)}">{detail}</td></tr>'
f'<tr><td class="cell-time" title="{attr(row.created_raw)}">{row.created}</td>'
f'<td class="cell-hostname" title="{attr(row.hostname_raw)}">{row.hostname}</td>'
f'<td class="cell-status">{row.status}</td><td class="cell-ip">{row.ipv4}</td>'
f'<td class="cell-ip">{row.ipv6}</td><td class="cell-count">{row.count}</td>'
f'<td class="cell-detail" title="{attr(row.detail_raw)}">{row.detail}</td></tr>'
)
+402 -209
View File
@@ -1,9 +1,10 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING
from ..services.ddns import DdnsService
from ..settings import DdnsSettings
from .components import (
_code_window,
_copy_button,
@@ -14,229 +15,383 @@ from .components import (
_secret_notice,
)
from .html_utils import SafeHtml, attr, escape, trusted
from .layout import _page, _top_nav
from .layout import PageOptions, _page, _top_nav
if TYPE_CHECKING:
from ..settings import DdnsSettings
def _render_management_page(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: Mapping[str, object],
csrf_token: str,
message: str | None = None,
request_ip: str | None = None,
) -> str:
account_for_url = {
"hostname": str(account["hostname"]),
"update_slug": str(account["update_slug"]),
"management_slug": management_slug,
}
update_url = service.fritz_update_url(account_for_url)
hostname = escape(account["hostname"])
ipv4 = escape(account.get("ipv4") or "-")
ipv6 = escape(account.get("ipv6") or "-")
updated = escape(account.get("updated_at") or "Never")
fritzbox_fields = _fritzbox_fields(update_url, hostname)
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
manual_commands = _manual_update_commands(update_url, request_ip)
history_rows = (
"\n".join(
_user_event_row(event)
for event in service.store.list_update_events_for_hostname(str(account["hostname"]), 10)
)
or """
<tr><td colspan="6" class="empty">No router updates yet.</td></tr>
"""
@dataclass(frozen=True)
class ManagementPageContext:
management_slug: str
account: Mapping[str, object]
csrf_token: str
message: str | None = None
request_ip: str | None = None
@property
def account_for_url(self) -> dict[str, str]:
return {
"hostname": str(self.account["hostname"]),
"update_slug": str(self.account["update_slug"]),
"management_slug": self.management_slug,
}
@dataclass(frozen=True, slots=True)
class _ManagementPageView:
update_url: str
hostname: str
ipv4: str
ipv6: str
updated: str
fritzbox_fields: str
cname_record: str
manual_commands: str
def _management_page_view(update_url: str, context: ManagementPageContext) -> _ManagementPageView:
hostname = escape(context.account["hostname"])
return _ManagementPageView(
update_url=update_url,
hostname=hostname,
ipv4=escape(context.account.get("ipv4") or "-"),
ipv6=escape(context.account.get("ipv6") or "-"),
updated=escape(context.account.get("updated_at") or "Never"),
fritzbox_fields=_fritzbox_fields(update_url, hostname),
cname_record=f"your-subdomain.example.com. CNAME {hostname}.",
manual_commands=_manual_update_commands(update_url, context.request_ip),
)
message_html = _management_message(message)
@dataclass(frozen=True)
class PendingManagementPageContext:
settings: DdnsSettings
management_slug: str
account: Mapping[str, object]
csrf_token: str
message: str | None = None
request_ip: str | None = None
@property
def account_for_url(self) -> dict[str, str]:
return {
"hostname": str(self.account["hostname"]),
"update_slug": str(self.account["update_slug"]),
"management_slug": self.management_slug,
}
def _render_management_page(service: DdnsService, context: ManagementPageContext) -> str:
update_url = service.fritz_update_url(context.account_for_url)
view = _management_page_view(update_url, context)
message_html = _management_message(context.message)
dashboard_password_panel = _dashboard_password_panel(
management_slug, csrf_token, bool(account.get("dashboard_password_hash"))
context.management_slug,
context.csrf_token,
bool(context.account.get("dashboard_password_hash")),
)
dashboard_shell = _management_dashboard_shell(context, view, message_html)
history_section = _management_history_section(_management_history_rows(service, context))
delete_section = _management_delete_section(context)
return _page(
"KarlDNS management",
trusted(f"""
<main id="main" class="dashboard-page">
{_top_nav()}
<section class="dashboard-shell">
<div class="container">
<div class="dashboard-header">
<div>
<p class="eyebrow">Dashboard</p>
<h1>{hostname}</h1>
<p class="lead">Private management link. Keep it safe.</p>
</div>
<a class="btn btn-outline-primary rounded-pill" href="/">Home</a>
</div>
{message_html}
<div class="dashboard-grid">
<section class="dashboard-card dashboard-card-main" aria-labelledby="router-settings-title">
<div class="dashboard-card-header">
<div>
<p class="eyebrow">Router settings</p>
<h2 id="router-settings-title">Update endpoint</h2>
</div>
<form method="post" action="/m/{attr(management_slug)}/rotate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<input type="hidden" name="action" value="update">
<button class="btn btn-outline-primary rounded-pill" type="submit">New update URL</button>
</form>
</div>
<p class="section-copy">Paste the Update-URL into your router. Use the hostname as Domainnamen, or point your own subdomain at it with a CNAME.</p>
{_secret_notice()}
<div class="dashboard-copy-stack">
{_copy_row("Update-URL:", update_url, "Secret")}
{_copy_row("Domainnamen:", hostname, "Public")}
{_copy_row("CNAME target:", hostname, "Public")}
</div>
<div class="dashboard-actions">
{_copy_button("Router fields", fritzbox_fields)}
{_copy_button("Copy CNAME", cname_record, style="outline-secondary")}
</div>
</section>
<section class="dashboard-card dashboard-status-card" aria-labelledby="status-title">
<p class="eyebrow">Current address</p>
<h2 id="status-title">Status</h2>
<dl class="dashboard-status-list">
<div><dt>IPv4</dt><dd>{ipv4}</dd></div>
<div><dt>IPv6</dt><dd>{ipv6}</dd></div>
<div><dt>Updated</dt><dd>{updated}</dd></div>
</dl>
</section>
</div>
<section class="dashboard-card dashboard-card-compact" aria-labelledby="manual-updates-title">
<div class="dashboard-card-header">
<div>
<p class="eyebrow">Manual updates</p>
<h2 id="manual-updates-title">curl examples</h2>
</div>
</div>
<p class="section-copy">FRITZ!Box fills IP placeholders automatically. Scripts can send an IP explicitly or omit it and let KarlDNS use the request IP.</p>
{_code_window(manual_commands, copy_label="manual update commands", class_name="manual-code")}
</section>
</div>
</section>
{dashboard_shell}
{dashboard_password_panel}
<section class="dashboard-section">
<div class="container">
<div class="dashboard-section-header">
<div>
<p class="eyebrow">History</p>
<h2>Router updates</h2>
<p class="section-copy">Recent IP changes and failed updates for this hostname.</p>
</div>
</div>
<div class="table-responsive dashboard-table-shell">
<table class="table table-hover dashboard-history-table mb-0">
<thead><tr><th>Time</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Count</th><th>Detail</th></tr></thead>
<tbody>{history_rows}</tbody>
</table>
</div>
</div>
</section>
<section class="dashboard-section dashboard-danger-section">
<div class="container">
<div class="dashboard-danger-card">
<div>
<p class="eyebrow">Delete</p>
<h2>Delete hostname</h2>
<p class="section-copy">Remove this hostname and its DNS records. The update URL will stop working.</p>
</div>
<form method="post" action="/m/{attr(management_slug)}/delete">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<button class="btn btn-outline-danger rounded-pill" type="submit">Delete hostname</button>
</form>
</div>
</div>
</section>
{history_section}
{delete_section}
</main>
"""),
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
PageOptions(extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">')),
)
def _render_pending_management_page(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: Mapping[str, object],
csrf_token: str,
message: str | None = None,
request_ip: str | None = None,
) -> str:
raw_hostname = str(account["hostname"])
account_for_url = {
"hostname": raw_hostname,
"update_slug": str(account["update_slug"]),
"management_slug": management_slug,
}
update_url = service.fritz_update_url(account_for_url)
dashboard_url = service.dashboard_url({"management_slug": management_slug})
def _management_history_rows(service: DdnsService, context: ManagementPageContext) -> SafeHtml:
events = service.store.list_update_events_for_hostname(str(context.account["hostname"]), 10)
rows = "\n".join(_user_event_row(event) for event in events)
return trusted(rows or """
<tr class="empty-row">
<td colspan="6" class="empty">
<div class="empty-state">
<div class="empty-state-title">No router updates yet.</div>
<div class="empty-state-description">
This table fills after the router or a script sends its first address update.
</div>
</div>
</td>
</tr>
""")
def _management_dashboard_shell(
context: ManagementPageContext,
view: _ManagementPageView,
message_html: SafeHtml,
) -> SafeHtml:
return trusted(f"""
<section class="dashboard-shell">
<div class="container">
{_management_dashboard_header(view)}
{message_html}
<div class="dashboard-grid">
{_router_settings_card(context, view)}
{_status_card(view)}
</div>
{_manual_updates_card(view)}
</div>
</section>
""")
def _management_dashboard_header(view: _ManagementPageView) -> SafeHtml:
return trusted(f"""
<div class="dashboard-header">
<div>
<p class="eyebrow">Dashboard</p>
<h1>{view.hostname}</h1>
<p class="lead">Private management link. Keep it safe.</p>
</div>
<a class="btn btn-outline-primary rounded-pill" href="/">Home</a>
</div>
""")
def _router_settings_card(context: ManagementPageContext, view: _ManagementPageView) -> SafeHtml:
return trusted(f"""
<section class="dashboard-card dashboard-card-main" data-slot="card" aria-labelledby="router-settings-title">
<div class="dashboard-card-header" data-slot="card-header">
<div>
<p class="eyebrow">Router settings</p>
<h2 id="router-settings-title">Update endpoint</h2>
</div>
<form method="post" action="/m/{attr(context.management_slug)}/rotate">
<input type="hidden" name="csrf" value="{attr(context.csrf_token)}">
<input type="hidden" name="action" value="update">
<button class="btn btn-outline-primary rounded-pill" type="submit">New update URL</button>
</form>
</div>
<div data-slot="card-content">
<p class="section-copy">
Paste the Update-URL into your router. Use the hostname as Domainnamen,
or point your own subdomain at it with a CNAME.
</p>
{_secret_notice()}
<div class="dashboard-copy-stack">
{_copy_row("Update-URL:", view.update_url, "Secret")}
{_copy_row("Domainnamen:", view.hostname, "Public")}
{_copy_row("CNAME target:", view.hostname, "Public")}
</div>
</div>
<div class="dashboard-actions" data-slot="card-footer">
{_copy_button("Router fields", view.fritzbox_fields)}
{_copy_button("Copy CNAME", view.cname_record, style="outline-secondary")}
</div>
</section>
""")
def _status_card(view: _ManagementPageView) -> SafeHtml:
return trusted(f"""
<section class="dashboard-card dashboard-status-card" data-slot="card" aria-labelledby="status-title">
<div data-slot="card-header">
<p class="eyebrow">Current address</p>
<h2 id="status-title">Status</h2>
</div>
<div data-slot="card-content">
<dl class="dashboard-status-list">
<div><dt>IPv4</dt><dd>{view.ipv4}</dd></div>
<div><dt>IPv6</dt><dd>{view.ipv6}</dd></div>
<div><dt>Updated</dt><dd>{view.updated}</dd></div>
</dl>
</div>
</section>
""")
def _manual_updates_card(view: _ManagementPageView) -> SafeHtml:
return trusted(f"""
<section class="dashboard-card dashboard-card-compact" data-slot="card" aria-labelledby="manual-updates-title">
<div class="dashboard-card-header" data-slot="card-header">
<div>
<p class="eyebrow">Manual updates</p>
<h2 id="manual-updates-title">curl examples</h2>
</div>
</div>
<div data-slot="card-content">
<p class="section-copy">
FRITZ!Box fills IP placeholders automatically. Scripts can send an IP explicitly
or omit it and let KarlDNS use the request IP.
</p>
{_code_window(view.manual_commands, copy_label="manual update commands", class_name="manual-code")}
</div>
</section>
""")
def _management_history_section(history_rows: SafeHtml) -> SafeHtml:
return trusted(f"""
<section class="dashboard-section">
<div class="container">
<div class="dashboard-section-header">
<div>
<p class="eyebrow">History</p>
<h2>Router updates</h2>
<p class="section-copy">Recent IP changes and failed updates for this hostname.</p>
</div>
</div>
<div class="table-responsive dashboard-table-shell">
<table class="table table-hover dashboard-history-table mb-0">
<thead><tr><th>Time</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Count</th><th>Detail</th></tr></thead>
<tbody>{history_rows}</tbody>
</table>
</div>
</div>
</section>
""")
def _management_delete_section(context: ManagementPageContext) -> SafeHtml:
return trusted(f"""
<section class="dashboard-section dashboard-danger-section">
<div class="container">
<div class="dashboard-danger-card" data-slot="card">
<div>
<p class="eyebrow">Delete</p>
<h2>Delete hostname</h2>
<p class="section-copy">
Remove this hostname and its DNS records. The update URL will stop working.
</p>
</div>
<form method="post" action="/m/{attr(context.management_slug)}/delete">
<input type="hidden" name="csrf" value="{attr(context.csrf_token)}">
<button class="btn btn-outline-danger rounded-pill" type="submit">Delete hostname</button>
</form>
</div>
</div>
</section>
""")
def _render_pending_management_page(service: DdnsService, context: PendingManagementPageContext) -> str:
raw_hostname = str(context.account["hostname"])
update_url = service.fritz_update_url(context.account_for_url)
dashboard_url = service.dashboard_url({"management_slug": context.management_slug})
hostname = escape(raw_hostname)
message_html = _management_message(message)
cleanup_hours = settings.cleanup_pending_account_hours
message_html = _management_message(context.message)
cleanup_hours = context.settings.cleanup_pending_account_hours
cleanup_text = f"{cleanup_hours} hours" if cleanup_hours != 24 else "24 hours"
manual_commands = _manual_update_commands(update_url, request_ip)
pending_lead = (
"Save these values, then register the hostname. Pending setups are cleaned up after " f"{cleanup_text}."
)
manual_commands = _manual_update_commands(update_url, context.request_ip)
hero = _pending_hero(hostname, pending_lead)
values_section = _pending_values_section(context, update_url, raw_hostname, dashboard_url)
router_section = _pending_router_section(hostname, update_url, manual_commands)
return _page(
"KarlDNS setup",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Pending setup</p>
<h1>{hostname}</h1>
<p class="lead">Save these values, then register the hostname. Pending setups are cleaned up after {escape(cleanup_text)}.</p>
</div>
</section>
{hero}
{message_html}
<section class="section">
<div class="container narrow d-grid gap-4">
<div>
<p class="eyebrow">Save</p>
<h2>Your values.</h2>
<p class="section-copy">Save the update URL and dashboard link before you register. After registration, the dashboard hides secrets by default.</p>
<p class="section-copy"><strong>Public:</strong> Domainnamen. <strong>Secret:</strong> Update-URL and private dashboard.</p>
</div>
<div class="d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret", mask_secret=False)}
{_copy_row("Domainnamen:", raw_hostname, "Public")}
{_copy_row("Private dashboard:", dashboard_url, "Secret", mask_secret=False)}
</div>
<form method="post" action="/m/{attr(management_slug)}/activate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
</form>
</div>
</section>
<section class="section section-compact">
<div class="container narrow d-grid gap-4">
<div>
<p class="eyebrow">FRITZ!Box</p>
<h2>Router setup.</h2>
<p class="section-copy">Paste this exact URL into the FRITZ!Box <strong>Update-URL</strong> field. Use <strong>{hostname}</strong> as <strong>Domainnamen</strong>. No KarlDNS username or password is needed; if your router requires them, enter any placeholder.</p>
{_code_window(update_url, copy_label="FRITZ!Box update URL", class_name="mt-3")}
</div>
<div>
<h2>Manual updates.</h2>
<p class="section-copy">Scripts can send the IP explicitly, or omit it and let KarlDNS use the request IP. Visible examples use your public IP when KarlDNS or your browser can detect it.</p>
{_code_window(manual_commands, copy_label="manual update commands", class_name="manual-code mt-3")}
</div>
<div>
<h2>Use your own DNS name.</h2>
<p class="section-copy">Create a CNAME at your DNS provider that points to <strong>{hostname}</strong>. This is the same value as Domainnamen.</p>
</div>
</div>
</section>
{values_section}
{router_section}
</main>
"""),
)
def _pending_hero(hostname: str, pending_lead: str) -> SafeHtml:
return trusted(f"""
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Pending setup</p>
<h1>{hostname}</h1>
<p class="lead">{escape(pending_lead)}</p>
</div>
</section>
""")
def _pending_values_section(
context: PendingManagementPageContext,
update_url: str,
raw_hostname: str,
dashboard_url: str,
) -> SafeHtml:
return trusted(f"""
<section class="section">
<div class="container narrow d-grid gap-4">
<div>
<p class="eyebrow">Save</p>
<h2>Your values.</h2>
<p class="section-copy">
Save the update URL and dashboard link before you register. After registration,
the dashboard hides secrets by default.
</p>
<p class="section-copy">
<strong>Public:</strong> Domainnamen.
<strong>Secret:</strong> Update-URL and private dashboard.
</p>
</div>
<div class="d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret", mask_secret=False)}
{_copy_row("Domainnamen:", raw_hostname, "Public")}
{_copy_row("Private dashboard:", dashboard_url, "Secret", mask_secret=False)}
</div>
<form method="post" action="/m/{attr(context.management_slug)}/activate">
<input type="hidden" name="csrf" value="{attr(context.csrf_token)}">
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
</form>
</div>
</section>
""")
def _pending_router_section(hostname: str, update_url: str, manual_commands: str) -> SafeHtml:
return trusted(f"""
<section class="section section-compact">
<div class="container narrow d-grid gap-4">
<div>
<p class="eyebrow">FRITZ!Box</p>
<h2>Router setup.</h2>
<p class="section-copy">
Paste this exact URL into the FRITZ!Box <strong>Update-URL</strong> field.
Use <strong>{hostname}</strong> as <strong>Domainnamen</strong>.
No KarlDNS username or password is needed; if your router requires them,
enter any placeholder.
</p>
{_code_window(update_url, copy_label="FRITZ!Box update URL", class_name="mt-3")}
</div>
<div>
<h2>Manual updates.</h2>
<p class="section-copy">
Scripts can send the IP explicitly, or omit it and let KarlDNS use the request IP.
Visible examples use your public IP when KarlDNS or your browser can detect it.
</p>
{_code_window(manual_commands, copy_label="manual update commands", class_name="manual-code mt-3")}
</div>
<div>
<h2>Use your own DNS name.</h2>
<p class="section-copy">
Create a CNAME at your DNS provider that points to <strong>{hostname}</strong>.
This is the same value as Domainnamen.
</p>
</div>
</div>
</section>
""")
def _management_message(message: str | None) -> SafeHtml:
if not message:
return trusted("")
return trusted(f"""
<div class="alert alert-info mb-0 rounded-0 border-start-0 border-end-0" role="status">
<div class="alert alert-info ui-alert mb-0 rounded-0 border-start-0 border-end-0"
role="status" data-slot="alert">
<div class="container">{escape(message)}</div>
</div>
""")
@@ -248,10 +403,12 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
remove_form = ""
if password_enabled:
remove_form = f"""
<form class="dashboard-card dashboard-form-card" method="post" action="/m/{attr(management_slug)}/password/remove">
<form class="dashboard-card dashboard-form-card" data-slot="card" method="post"
action="/m/{attr(management_slug)}/password/remove">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<label class="form-label" for="dashboard-remove-current-password">Current password</label>
<input class="form-control" id="dashboard-remove-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
<input class="form-control" id="dashboard-remove-current-password"
name="current_dashboard_password" type="password" autocomplete="current-password" required>
<button class="btn btn-outline-danger rounded-pill" type="submit">Remove password</button>
</form>
"""
@@ -260,7 +417,8 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
current_password_field = """
<div>
<label class="form-label" for="dashboard-current-password">Current password</label>
<input class="form-control" id="dashboard-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
<input class="form-control" id="dashboard-current-password"
name="current_dashboard_password" type="password" autocomplete="current-password" required>
</div>
"""
return trusted(f"""
@@ -270,19 +428,26 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
<div>
<p class="eyebrow">Dashboard password</p>
<h2>Password protection</h2>
<p class="section-copy">Status: {escape(password_status)}. This password protects only this dashboard. Router updates still work.</p>
<p class="section-copy">
Status: {escape(password_status)}. This password protects only this dashboard.
Router updates still work.
</p>
</div>
<div class="d-grid gap-3">
<form class="dashboard-card dashboard-form-card" method="post" action="/m/{attr(management_slug)}/password">
<form class="dashboard-card dashboard-form-card" data-slot="card" method="post"
action="/m/{attr(management_slug)}/password">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
{current_password_field}
<div>
<label class="form-label" for="dashboard-password">{escape(password_action)}</label>
<input class="form-control" id="dashboard-password" name="dashboard_password" type="password" autocomplete="new-password" minlength="8" required>
<input class="form-control" id="dashboard-password" name="dashboard_password"
type="password" autocomplete="new-password" minlength="8" required>
</div>
<div>
<label class="form-label" for="dashboard-password-confirm">Confirm password</label>
<input class="form-control" id="dashboard-password-confirm" name="dashboard_password_confirm" type="password" autocomplete="new-password" minlength="8" required>
<input class="form-control" id="dashboard-password-confirm"
name="dashboard_password_confirm" type="password" autocomplete="new-password"
minlength="8" required>
</div>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-primary rounded-pill" type="submit">{escape(password_action)}</button>
@@ -305,7 +470,9 @@ def _render_management_unlock_page(
hostname = escape(account["hostname"])
message_html = ""
if message:
message_html = f'<div class="alert alert-warning" role="alert">{escape(message)}</div>'
message_html = (
f'<div class="alert alert-warning ui-alert" role="alert" data-slot="alert">{escape(message)}</div>'
)
return _page(
"Unlock KarlDNS management",
trusted(f"""
@@ -321,11 +488,13 @@ def _render_management_unlock_page(
<section class="section">
<div class="container narrow">
{message_html}
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/unlock">
<form class="card card-body gap-3" data-slot="card" method="post"
action="/m/{attr(management_slug)}/unlock">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<div>
<label class="form-label" for="dashboard-unlock-password">Dashboard password</label>
<input class="form-control" id="dashboard-unlock-password" name="dashboard_password" type="password" autocomplete="current-password" required autofocus>
<input class="form-control" id="dashboard-unlock-password" name="dashboard_password"
type="password" autocomplete="current-password" required autofocus>
</div>
<label class="form-check d-flex align-items-center gap-2">
<input class="form-check-input" type="checkbox" name="stay_unlocked" value="1">
@@ -341,15 +510,39 @@ def _render_management_unlock_page(
def _user_event_row(event: Mapping[str, object]) -> str:
created = escape(event.get("created_at") or "-")
row = _user_event_row_view(event)
return (
f'<tr><td>{row.created}</td><td><span class="status-badge {attr(row.badge_class)}">{row.status}</span></td>'
f"<td>{row.ipv4}</td><td>{row.ipv6}</td><td>{row.count}</td><td>{row.detail}</td></tr>"
)
@dataclass(frozen=True, slots=True)
class _UserEventRow:
created: str
status: str
badge_class: str
ipv4: str
ipv6: str
count: str
detail: str
def _user_event_row_view(event: Mapping[str, object]) -> _UserEventRow:
raw_status = str(event.get("status") or "")
status = escape(_event_label(raw_status))
badge_class = attr(_event_badge_class(raw_status))
ipv4 = escape(event.get("ipv4") or "-")
ipv6 = escape(event.get("ipv6") or "-")
count = escape(event.get("count") or 1)
detail = escape(event.get("detail") or "-")
return f'<tr><td>{created}</td><td><span class="status-badge {badge_class}">{status}</span></td><td>{ipv4}</td><td>{ipv6}</td><td>{count}</td><td>{detail}</td></tr>'
return _UserEventRow(
created=escape(_event_text(event, "created_at")),
status=escape(_event_label(raw_status)),
badge_class=_event_badge_class(raw_status),
ipv4=escape(_event_text(event, "ipv4")),
ipv6=escape(_event_text(event, "ipv6")),
count=escape(event.get("count") or 1),
detail=escape(_event_text(event, "detail")),
)
def _event_text(event: Mapping[str, object], key: str) -> str:
return str(event.get(key) or "-")
def _event_badge_class(status: str) -> str:
+223 -145
View File
@@ -1,12 +1,25 @@
from __future__ import annotations
from dataclasses import dataclass
from ..settings import DdnsSettings
from .components import _code_window, _example_update_ips, _message_band
from .html_utils import escape, trusted
from .layout import _page, _top_nav
from .html_utils import trusted
from .layout import PageOptions, _page, _top_nav
from .sections.public_home import _manual_updates_section, _public_home_hero, _setup_flow_section
from .templates_public_sections import _linux_installer_section
@dataclass(frozen=True, slots=True)
class _ApiDocsExamples:
create: str
register: str
update: str
status: str
rotate: str
schema: str
def _render_public_home(
settings: DdnsSettings,
message: str | None,
@@ -14,8 +27,7 @@ def _render_public_home(
) -> str:
base_url = settings.public_base_url.rstrip("/")
hostname_suffix = settings.hostname_suffix or "your KarlDNS suffix"
suffix = escape(hostname_suffix)
demo_update_url = escape(f"{base_url}/api/v1/router/update/<secret>?myip=<ipaddr>")
demo_update_url = f"{base_url}/api/v1/router/update/<secret>?myip=<ipaddr>"
example_ipv4, _ = _example_update_ips(request_ip)
create_demo_command = f"curl -sS '{base_url}/api/v1/router/update/<secret>?myip={example_ipv4}'"
create_demo = f"{create_demo_command}\ngood {example_ipv4}"
@@ -28,81 +40,55 @@ def _render_public_home(
]
)
message_html = _message_band(message) if message else ""
description = "KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, generated KarlDNS hostnames, Linux cron jobs, and home networks."
description = (
"KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, "
"generated KarlDNS hostnames, Linux cron jobs, and home networks."
)
return _page(
"KarlDNS",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band">
<div class="container text-center d-grid justify-items-center gap-3">
<img class="hero-logo" src="/logo.webp?v=3" alt="" width="80" height="80" decoding="async" fetchpriority="high">
<h1>Dynamic DNS for home routers.</h1>
<div class="install-pill mx-auto">
<code>{demo_update_url}</code>
</div>
<p class="lead">KarlDNS gives you a generated DynDNS hostname under {suffix}. Add the private update URL to your router, or use it from a script.</p>
<div class="d-flex gap-2 flex-wrap justify-content-center mt-2">
<form method="post" action="/" class="hero-action-form" aria-label="Create a generated DynDNS hostname">
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
</form>
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">Manual updates</a>
</div>
</div>
</section>
{_public_home_hero(suffix=hostname_suffix, demo_update_url=demo_update_url)}
{message_html}
<section class="section section-compact">
<div class="container narrow text-center">
<p class="eyebrow">How it works</p>
<h2>No account needed.</h2>
<p class="section-copy mx-auto">Create a hostname, save the private values, register it, done.</p>
<ol class="flow-steps" aria-label="KarlDNS setup flow">
<li><strong>Create</strong><span>Random hostname and private links.</span></li>
<li><strong>Save</strong><span>Copy the secrets while they are shown.</span></li>
<li><strong>Register</strong><span>Activate the hostname when you are ready.</span></li>
<li><strong>Update</strong><span>Router, cron, or CNAME can use it.</span></li>
</ol>
{_code_window(create_demo, copy_label="update example", class_name="mt-4", copy_value=create_demo_command)}
</div>
</section>
<section class="section" id="manual-updates">
<div class="container narrow text-center">
<h2>Update from a router or script.</h2>
<p class="lead mx-auto mt-3">Works with FRITZ!Box, curl, cron, Home Assistant, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, and custom tools.</p>
{_code_window(manual_demo, copy_label="manual update examples", class_name="manual-code text-start mt-4")}
</div>
</section>
{_setup_flow_section(create_demo=create_demo, create_demo_command=create_demo_command)}
{_manual_updates_section(manual_demo=manual_demo)}
{_linux_installer_section(base_url)}
</main>
"""),
description=description,
preload_logo=True,
canonical_url=f"{base_url}/",
image_url=f"{base_url}/social-preview.png",
image_alt="KarlDNS landing page with private Dynamic DNS update URL",
structured_data={
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "KarlDNS",
"applicationCategory": "NetworkingApplication",
"operatingSystem": "Linux",
"url": f"{base_url}/",
"description": description,
"image": f"{base_url}/social-preview.png",
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "EUR"},
"featureList": [
"Dynamic DNS for FRITZ!Box routers",
"Accountless private update URLs",
"CNAME target for external DNS names",
"Linux cron updater",
],
},
PageOptions(
description=description,
preload_logo=True,
canonical_url=f"{base_url}/",
image_url=f"{base_url}/social-preview.png",
image_alt="KarlDNS landing page with private Dynamic DNS update URL",
structured_data={
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "KarlDNS",
"applicationCategory": "NetworkingApplication",
"operatingSystem": "Linux",
"url": f"{base_url}/",
"description": description,
"image": f"{base_url}/social-preview.png",
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "EUR"},
"featureList": [
"Dynamic DNS for FRITZ!Box routers",
"Accountless private update URLs",
"CNAME target for external DNS names",
"Linux cron updater",
],
},
),
)
def _render_privacy_page(settings: DdnsSettings) -> str:
base_url = settings.public_base_url.rstrip("/")
description = "Privacy and legal information for KarlDNS, including stored hostnames, update links, IP history, and cleanup behavior."
description = (
"Privacy and legal information for KarlDNS, including stored hostnames, update links, "
"IP history, and cleanup behavior."
)
return _page(
"Legal and Privacy",
trusted(f"""
@@ -119,113 +105,205 @@ def _render_privacy_page(settings: DdnsSettings) -> str:
<div class="container narrow d-grid gap-4">
<div>
<h2>Data processed</h2>
<p class="section-copy">KarlDNS stores hostnames, protected dashboard tokens, private update links, recent IP history, timestamps, and service events.</p>
<p class="section-copy">
KarlDNS stores hostnames, protected dashboard tokens, private update links,
recent IP history, timestamps, and service events.
</p>
</div>
<div>
<h2>Secrets</h2>
<p class="section-copy">Dashboard links, registration URLs, and update URLs are secrets. Anyone with them may update or manage the hostname. Dashboard lookup values are not stored as readable text.</p>
<p class="section-copy">
Dashboard links, registration URLs, and update URLs are secrets.
Anyone with them may update or manage the hostname.
Dashboard lookup values are not stored as readable text.
</p>
</div>
<div>
<h2>Logs and retention</h2>
<p class="section-copy">KarlDNS keeps recent update events for troubleshooting. Cleanup removes unused hostnames. Server logs may also include IP addresses and request details.</p>
<p class="section-copy">
KarlDNS keeps recent update events for troubleshooting. Cleanup removes unused hostnames.
Server logs may also include IP addresses and request details.
</p>
</div>
<div>
<h2>Contact</h2>
<p class="section-copy">For the public KarlDNS instance, contact Karl through <a href="https://karl.fail" rel="noopener">karl.fail</a> or <a href="https://karlcom.de" rel="noopener">karlcom.de</a>.</p>
<p class="section-copy">
For the public KarlDNS instance, contact Karl through
<a href="https://karl.fail" rel="noopener">karl.fail</a> or
<a href="https://karlcom.de" rel="noopener">karlcom.de</a>.
</p>
</div>
</div>
</section>
</main>
"""),
description=description,
canonical_url=f"{base_url}/privacy",
image_url=f"{base_url}/social-preview.png",
image_alt="KarlDNS legal and privacy information",
structured_data={
"@context": "https://schema.org",
"@type": "WebPage",
"name": "KarlDNS Legal and Privacy",
"url": f"{base_url}/privacy",
"description": description,
"isPartOf": {
"@type": "WebSite",
"name": "KarlDNS",
"url": f"{base_url}/",
PageOptions(
description=description,
canonical_url=f"{base_url}/privacy",
image_url=f"{base_url}/social-preview.png",
image_alt="KarlDNS legal and privacy information",
structured_data={
"@context": "https://schema.org",
"@type": "WebPage",
"name": "KarlDNS Legal and Privacy",
"url": f"{base_url}/privacy",
"description": description,
"isPartOf": {
"@type": "WebSite",
"name": "KarlDNS",
"url": f"{base_url}/",
},
},
},
),
)
def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None) -> str:
base_url = settings.public_base_url.rstrip("/")
example_ipv4, _ = _example_update_ips(request_ip)
create_example = f"curl -sS -X POST {base_url}/api/v1/hostnames"
register_example = (
f"curl -sS -X POST '{base_url}/api/v1/router/register/<karl_dashboard_secret>?myip={example_ipv4}'"
examples = _api_docs_examples(base_url, example_ipv4)
description = (
"Small KarlDNS API guide for creating, activating, updating, reading, and deleting "
"generated Dynamic DNS hostnames."
)
update_example = f"curl -sS '{base_url}/api/v1/router/update/<karl_update_secret>?myip={example_ipv4}'"
status_example = f"curl -sS '{base_url}/api/v1/hostnames/<karl_dashboard_secret>?history_limit=10'"
rotate_example = f"curl -sS -X POST {base_url}/api/v1/hostnames/<karl_dashboard_secret>/rotate-update-url"
schema_example = f"curl -sS {base_url}/openapi.json"
description = "Small KarlDNS API guide for creating, activating, updating, reading, and deleting generated Dynamic DNS hostnames."
return _page(
"KarlDNS API",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">API</p>
<h1>Small API surface.</h1>
<p class="lead">Create a hostname, register it, update it, and read its status.</p>
</div>
</section>
<section class="section section-compact">
<div class="container narrow d-grid gap-4">
<div class="api-endpoints">
<h2>Endpoints</h2>
<ol class="simple-steps text-start">
<li><strong>POST /api/v1/hostnames.</strong> Create a pending hostname and private setup values. The response includes <code>registration_url</code>.</li>
<li><strong>GET or POST /api/v1/router/register/&lt;karl_secret&gt;.</strong> Register the hostname after secrets are saved. Publishes an initial DNS record from a public request IP or explicit <code>myip</code>/<code>myipv6</code> value. If no public IP is visible, publishes a temporary <code>192.0.2.1</code> A record until the first router update.</li>
<li><strong>GET or POST /api/v1/router/update/&lt;karl_secret&gt;.</strong> Router-friendly update endpoint. Supports <code>myip</code>, <code>ipaddr</code>, <code>myipv6</code>, and <code>ip6addr</code>.</li>
<li><strong>GET /api/v1/hostnames/&lt;karl_secret&gt;.</strong> Read hostname status and recent history.</li>
<li><strong>POST /api/v1/hostnames/&lt;karl_secret&gt;/rotate-update-url.</strong> Replace the private router update URL.</li>
<li><strong>DELETE /api/v1/hostnames/&lt;karl_secret&gt;.</strong> Delete hostname and DNS records.</li>
</ol>
</div>
<div>
<h2>Create</h2>
{_code_window(create_example, copy_label="create command", class_name="mt-3")}
</div>
<div>
<h2>Register</h2>
{_code_window(register_example, copy_label="register command", class_name="mt-3")}
</div>
<div>
<h2>Update</h2>
{_code_window(update_example, copy_label="update command", class_name="mt-3")}
</div>
<div>
<h2>Status</h2>
<p class="section-copy">Use the private dashboard secret to read current DNS state and recent updates for only that hostname.</p>
{_code_window(status_example, copy_label="status command", class_name="mt-3")}
</div>
<div>
<h2>Rotate update URL</h2>
<p class="section-copy">Use the private dashboard secret to replace the router update URL. The old update URL stops working immediately.</p>
{_code_window(rotate_example, copy_label="rotate command", class_name="mt-3")}
</div>
<div>
<h2>Schema</h2>
<p class="section-copy">Machine-readable OpenAPI is available for the public hostname and router endpoints.</p>
{_code_window(schema_example, copy_label="schema command", class_name="mt-3")}
</div>
</div>
</section>
{_api_docs_hero()}
{_api_docs_content(examples)}
</main>
"""),
description=description,
canonical_url=f"{base_url}/docs",
image_url=f"{base_url}/social-preview.png",
image_alt="KarlDNS API documentation",
PageOptions(
description=description,
canonical_url=f"{base_url}/docs",
image_url=f"{base_url}/social-preview.png",
image_alt="KarlDNS API documentation",
),
)
def _api_docs_examples(base_url: str, example_ipv4: str) -> _ApiDocsExamples:
return _ApiDocsExamples(
create=f"curl -sS -X POST {base_url}/api/v1/hostnames",
register=f"curl -sS -X POST '{base_url}/api/v1/router/register/<karl_dashboard_secret>?myip={example_ipv4}'",
update=f"curl -sS '{base_url}/api/v1/router/update/<karl_update_secret>?myip={example_ipv4}'",
status=f"curl -sS '{base_url}/api/v1/hostnames/<karl_dashboard_secret>?history_limit=10'",
rotate=f"curl -sS -X POST {base_url}/api/v1/hostnames/<karl_dashboard_secret>/rotate-update-url",
schema=f"curl -sS {base_url}/openapi.json",
)
def _api_docs_hero() -> str:
return """
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">API</p>
<h1>Small API surface.</h1>
<p class="lead">Create a hostname, register it, update it, and read its status.</p>
</div>
</section>
"""
def _api_docs_content(examples: _ApiDocsExamples) -> str:
return f"""
<section class="section section-compact">
<div class="container narrow d-grid gap-4">
{_api_endpoints_overview()}
{_api_example_block("Create", examples.create, "create command")}
{_api_example_block("Register", examples.register, "register command")}
{_api_example_block("Update", examples.update, "update command")}
{_api_status_block(examples.status)}
{_api_rotate_block(examples.rotate)}
{_api_schema_block(examples.schema)}
</div>
</section>
"""
def _api_endpoints_overview() -> str:
return """
<div class="api-endpoints">
<h2>Endpoints</h2>
<ol class="simple-steps text-start">
<li>
<strong>POST /api/v1/hostnames.</strong>
Create a pending hostname and private setup values. The response includes
<code>registration_url</code>.
</li>
<li>
<strong>GET or POST /api/v1/router/register/&lt;karl_secret&gt;.</strong>
Register the hostname after secrets are saved. Publishes an initial DNS record
from a public request IP or explicit <code>myip</code>/<code>myipv6</code> value.
If no public IP is visible, publishes a temporary <code>192.0.2.1</code> A record
until the first router update.
</li>
<li>
<strong>GET or POST /api/v1/router/update/&lt;karl_secret&gt;.</strong>
Router-friendly update endpoint. Supports <code>myip</code>, <code>ipaddr</code>,
<code>myipv6</code>, and <code>ip6addr</code>.
</li>
<li>
<strong>GET /api/v1/hostnames/&lt;karl_secret&gt;.</strong>
Read hostname status and recent history.
</li>
<li>
<strong>POST /api/v1/hostnames/&lt;karl_secret&gt;/rotate-update-url.</strong>
Replace the private router update URL.
</li>
<li>
<strong>DELETE /api/v1/hostnames/&lt;karl_secret&gt;.</strong>
Delete hostname and DNS records.
</li>
</ol>
</div>
"""
def _api_example_block(title: str, command: str, copy_label: str) -> str:
return f"""
<div>
<h2>{title}</h2>
{_code_window(command, copy_label=copy_label, class_name="mt-3")}
</div>
"""
def _api_status_block(command: str) -> str:
return f"""
<div>
<h2>Status</h2>
<p class="section-copy">
Use the private dashboard secret to read current DNS state and recent updates
for only that hostname.
</p>
{_code_window(command, copy_label="status command", class_name="mt-3")}
</div>
"""
def _api_rotate_block(command: str) -> str:
return f"""
<div>
<h2>Rotate update URL</h2>
<p class="section-copy">
Use the private dashboard secret to replace the router update URL.
The old update URL stops working immediately.
</p>
{_code_window(command, copy_label="rotate command", class_name="mt-3")}
</div>
"""
def _api_schema_block(command: str) -> str:
return f"""
<div>
<h2>Schema</h2>
<p class="section-copy">
Machine-readable OpenAPI is available for the public hostname and router endpoints.
</p>
{_code_window(command, copy_label="schema command", class_name="mt-3")}
</div>
"""
+5 -1
View File
@@ -14,7 +14,11 @@ def _linux_installer_section(base_url: str) -> SafeHtml:
<div class="text-center mb-4">
<p class="eyebrow">Linux cron updater</p>
<h2>Linux updater.</h2>
<p class="section-copy mx-auto">Run this on a Linux box that is always on. It creates and registers a hostname, installs the updater, schedules cron, and prints your private dashboard link. Works with Debian, Ubuntu, Fedora, Arch, Alpine, and Proxmox hosts.</p>
<p class="section-copy mx-auto">
Run this on a Linux box that is always on. It creates and registers a hostname,
installs the updater, schedules cron, and prints your private dashboard link.
Works with Debian, Ubuntu, Fedora, Arch, Alpine, and Proxmox hosts.
</p>
</div>
<div class="installer-commands">
<div class="installer-command">