Archived
Refactor UI assets and harden quality checks
This commit is contained in:
1 parent
23ab466c52
commit
4ecc8eb874
62 files changed
+3314
-3206
No files matched your search
@@ -3,6 +3,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
.codegraph/
|
||||||
.playwright-cli/
|
.playwright-cli/
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
*.sqlite3-*
|
*.sqlite3-*
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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
@@ -8,7 +8,7 @@ from fastapi.responses import PlainTextResponse
|
|||||||
|
|
||||||
from .dns import DnsProviderError
|
from .dns import DnsProviderError
|
||||||
from .security import credentials_from_basic_auth
|
from .security import credentials_from_basic_auth
|
||||||
from .services.ddns import BadIpError, DdnsService
|
from .services.ddns import BadIpError, DdnsService, UpdateIpParams
|
||||||
from .settings import DdnsSettings
|
from .settings import DdnsSettings
|
||||||
from .store import DdnsStore
|
from .store import DdnsStore
|
||||||
|
|
||||||
@@ -38,11 +38,8 @@ def authenticate_admin(
|
|||||||
if not settings.admin_password:
|
if not settings.admin_password:
|
||||||
raise HTTPException(status_code=500, detail="DDNS_ADMIN_PASSWORD is required")
|
raise HTTPException(status_code=500, detail="DDNS_ADMIN_PASSWORD is required")
|
||||||
|
|
||||||
supplied = None
|
supplied = _basic_auth_password(authorization)
|
||||||
if authorization and authorization.lower().startswith("basic "):
|
if supplied is None or not secrets.compare_digest(supplied, settings.admin_password):
|
||||||
_, supplied = credentials_from_basic_auth(authorization)
|
|
||||||
|
|
||||||
if not supplied or not secrets.compare_digest(supplied, settings.admin_password):
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail="admin authentication required",
|
detail="admin authentication required",
|
||||||
@@ -50,17 +47,21 @@ def authenticate_admin(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _basic_auth_password(authorization: str | None) -> str | None:
|
||||||
|
if authorization is None or not authorization.lower().startswith("basic "):
|
||||||
|
return None
|
||||||
|
_, password = credentials_from_basic_auth(authorization)
|
||||||
|
return password
|
||||||
|
|
||||||
|
|
||||||
def apply_update_response(
|
def apply_update_response(
|
||||||
service: DdnsService,
|
service: DdnsService,
|
||||||
request: Request,
|
request: Request,
|
||||||
hostname: str,
|
hostname: str,
|
||||||
myip: str | None,
|
ip_params: UpdateIpParams,
|
||||||
ipaddr: str | None,
|
|
||||||
myipv6: str | None,
|
|
||||||
ip6addr: str | None,
|
|
||||||
) -> str | PlainTextResponse:
|
) -> str | PlainTextResponse:
|
||||||
try:
|
try:
|
||||||
return service.apply_update(request, hostname, myip, ipaddr, myipv6, ip6addr).result.dyndns_response
|
return service.apply_update(request, hostname, ip_params).result.dyndns_response
|
||||||
except BadIpError:
|
except BadIpError:
|
||||||
return PlainTextResponse("badip", status_code=400)
|
return PlainTextResponse("badip", status_code=400)
|
||||||
except DnsProviderError:
|
except DnsProviderError:
|
||||||
|
|||||||
+24
-10
@@ -55,7 +55,7 @@ def delete_dns_records(hostname: str, settings: DdnsSettings) -> None:
|
|||||||
def dns_backend_configured(settings: DdnsSettings) -> bool:
|
def dns_backend_configured(settings: DdnsSettings) -> bool:
|
||||||
validate_dns_provider_settings(settings)
|
validate_dns_provider_settings(settings)
|
||||||
provider = _normalized_provider(settings)
|
provider = _normalized_provider(settings)
|
||||||
return provider == "cloudflare" or provider == "rfc2136" or bool(settings.rfc2136_server)
|
return _dns_backend_enabled(provider, settings)
|
||||||
|
|
||||||
|
|
||||||
def validate_dns_provider_settings(settings: DdnsSettings) -> None:
|
def validate_dns_provider_settings(settings: DdnsSettings) -> None:
|
||||||
@@ -65,19 +65,33 @@ def validate_dns_provider_settings(settings: DdnsSettings) -> None:
|
|||||||
if not settings.require_dns_provider:
|
if not settings.require_dns_provider:
|
||||||
return
|
return
|
||||||
if provider == "cloudflare":
|
if provider == "cloudflare":
|
||||||
if not settings.cloudflare_api_token or not settings.cloudflare_zone_id:
|
_validate_cloudflare_settings(settings)
|
||||||
raise DnsConfigurationError(
|
|
||||||
"DDNS_CLOUDFLARE_API_TOKEN and DDNS_CLOUDFLARE_ZONE_ID are required when DDNS_DNS_PROVIDER=cloudflare"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
if provider == "rfc2136" or settings.rfc2136_server:
|
if _dns_backend_enabled(provider, settings):
|
||||||
if not settings.rfc2136_server or not settings.rfc2136_zone:
|
_validate_rfc2136_settings(settings)
|
||||||
raise DnsConfigurationError(
|
|
||||||
"DDNS_RFC2136_SERVER and DDNS_RFC2136_ZONE are required when RFC2136 DNS publishing is enabled"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
raise DnsConfigurationError("DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured")
|
raise DnsConfigurationError("DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured")
|
||||||
|
|
||||||
|
|
||||||
|
def _dns_backend_enabled(provider: str, settings: DdnsSettings) -> bool:
|
||||||
|
return provider == "cloudflare" or provider == "rfc2136" or bool(settings.rfc2136_server)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_cloudflare_settings(settings: DdnsSettings) -> None:
|
||||||
|
if settings.cloudflare_api_token and settings.cloudflare_zone_id:
|
||||||
|
return
|
||||||
|
raise DnsConfigurationError(
|
||||||
|
"DDNS_CLOUDFLARE_API_TOKEN and DDNS_CLOUDFLARE_ZONE_ID are required when DDNS_DNS_PROVIDER=cloudflare"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_rfc2136_settings(settings: DdnsSettings) -> None:
|
||||||
|
if settings.rfc2136_server and settings.rfc2136_zone:
|
||||||
|
return
|
||||||
|
raise DnsConfigurationError(
|
||||||
|
"DDNS_RFC2136_SERVER and DDNS_RFC2136_ZONE are required when RFC2136 DNS publishing is enabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _normalized_provider(settings: DdnsSettings) -> str:
|
def _normalized_provider(settings: DdnsSettings) -> str:
|
||||||
return settings.dns_provider.strip().lower()
|
return settings.dns_provider.strip().lower()
|
||||||
@@ -78,15 +78,23 @@ def _cloudflare_request(
|
|||||||
request = urllib.request.Request(url, data=body, method=method)
|
request = urllib.request.Request(url, data=body, method=method)
|
||||||
request.add_header("Authorization", f"Bearer {settings.cloudflare_api_token}")
|
request.add_header("Authorization", f"Bearer {settings.cloudflare_api_token}")
|
||||||
request.add_header("Content-Type", "application/json")
|
request.add_header("Content-Type", "application/json")
|
||||||
|
parsed = _read_cloudflare_json(request)
|
||||||
|
_raise_for_cloudflare_error(parsed)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cloudflare_json(request: urllib.request.Request) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=10) as response:
|
with urllib.request.urlopen(request, timeout=10) as response:
|
||||||
parsed = json.loads(response.read().decode("utf-8"))
|
return cast(dict[str, Any], json.loads(response.read().decode("utf-8")))
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
raise DnsProviderError(f"Cloudflare API returned HTTP {exc.code}") from exc
|
raise DnsProviderError(f"Cloudflare API returned HTTP {exc.code}") from exc
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
raise DnsProviderError("Cloudflare API request failed") from exc
|
raise DnsProviderError("Cloudflare API request failed") from exc
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
raise DnsProviderError("Cloudflare API returned invalid JSON") from exc
|
raise DnsProviderError("Cloudflare API returned invalid JSON") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_for_cloudflare_error(parsed: dict[str, Any]) -> None:
|
||||||
if not parsed.get("success", False):
|
if not parsed.get("success", False):
|
||||||
raise DnsProviderError("Cloudflare API returned an unsuccessful response")
|
raise DnsProviderError("Cloudflare API returned an unsuccessful response")
|
||||||
return parsed
|
|
||||||
+89
-42
@@ -1,67 +1,114 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from ..models import UpdateResult
|
from ..models import UpdateResult
|
||||||
from ..settings import DdnsSettings
|
from ..settings import DdnsSettings
|
||||||
from .base import DnsProviderError
|
from .base import DnsProviderError
|
||||||
|
|
||||||
|
|
||||||
|
class _RcodeResponse(Protocol):
|
||||||
|
def rcode(self) -> int: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _RecordUpdate:
|
||||||
|
ttl: int
|
||||||
|
record_type: str
|
||||||
|
value: str | None
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
def publish_rfc2136(result: UpdateResult, settings: DdnsSettings) -> None:
|
def publish_rfc2136(result: UpdateResult, settings: DdnsSettings) -> None:
|
||||||
if not settings.rfc2136_server:
|
if not settings.rfc2136_server:
|
||||||
return
|
return
|
||||||
|
|
||||||
import dns.exception
|
update, relative = _rfc2136_update(result.hostname, settings)
|
||||||
import dns.query
|
_apply_update_records(update, relative, result, settings)
|
||||||
import dns.rcode
|
_send_rfc2136_update(update, settings.rfc2136_server, "update")
|
||||||
import dns.tsigkeyring
|
|
||||||
import dns.update
|
|
||||||
|
|
||||||
keyring = None
|
|
||||||
if settings.rfc2136_key_name and settings.rfc2136_key_secret:
|
|
||||||
keyring = dns.tsigkeyring.from_text({settings.rfc2136_key_name: settings.rfc2136_key_secret})
|
|
||||||
|
|
||||||
zone = settings.rfc2136_zone or ".".join(result.hostname.split(".")[1:])
|
|
||||||
update = dns.update.Update(zone, keyring=keyring)
|
|
||||||
relative = result.hostname.removesuffix("." + zone).rstrip(".") or "@"
|
|
||||||
if result.update_ipv4 and result.ipv4:
|
|
||||||
update.replace(relative, settings.ttl, "A", result.ipv4)
|
|
||||||
elif result.update_ipv4:
|
|
||||||
update.delete(relative, "A")
|
|
||||||
if result.update_ipv6 and result.ipv6:
|
|
||||||
update.replace(relative, settings.ttl, "AAAA", result.ipv6)
|
|
||||||
elif result.update_ipv6:
|
|
||||||
update.delete(relative, "AAAA")
|
|
||||||
try:
|
|
||||||
response = dns.query.tcp(update, settings.rfc2136_server, timeout=5)
|
|
||||||
except dns.exception.DNSException as exc:
|
|
||||||
raise DnsProviderError("RFC2136 DNS update failed") from exc
|
|
||||||
rcode = response.rcode()
|
|
||||||
if rcode != 0:
|
|
||||||
raise DnsProviderError(f"RFC2136 update failed: {dns.rcode.to_text(rcode)}")
|
|
||||||
|
|
||||||
|
|
||||||
def delete_rfc2136_records(hostname: str, settings: DdnsSettings) -> None:
|
def delete_rfc2136_records(hostname: str, settings: DdnsSettings) -> None:
|
||||||
if not settings.rfc2136_server:
|
if not settings.rfc2136_server:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
update, relative = _rfc2136_update(hostname, settings)
|
||||||
|
update.delete(relative, "A")
|
||||||
|
update.delete(relative, "AAAA")
|
||||||
|
_send_rfc2136_update(update, settings.rfc2136_server, "delete")
|
||||||
|
|
||||||
|
|
||||||
|
def _rfc2136_update(hostname: str, settings: DdnsSettings) -> tuple[Any, str]:
|
||||||
|
import dns.update
|
||||||
|
|
||||||
|
zone = _rfc2136_zone(hostname, settings)
|
||||||
|
update = dns.update.Update(zone, keyring=_rfc2136_keyring(settings))
|
||||||
|
return update, _rfc2136_relative_name(hostname, zone)
|
||||||
|
|
||||||
|
|
||||||
|
def _rfc2136_zone(hostname: str, settings: DdnsSettings) -> str:
|
||||||
|
return settings.rfc2136_zone or ".".join(hostname.split(".")[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def _rfc2136_relative_name(hostname: str, zone: str) -> str:
|
||||||
|
hostname = hostname.rstrip(".")
|
||||||
|
zone = zone.rstrip(".")
|
||||||
|
if hostname == zone:
|
||||||
|
return "@"
|
||||||
|
return hostname.removesuffix("." + zone).rstrip(".") or "@"
|
||||||
|
|
||||||
|
|
||||||
|
def _rfc2136_keyring(settings: DdnsSettings) -> Any | None:
|
||||||
|
if not settings.rfc2136_key_name or not settings.rfc2136_key_secret:
|
||||||
|
return None
|
||||||
|
|
||||||
|
import dns.tsigkeyring
|
||||||
|
|
||||||
|
return dns.tsigkeyring.from_text({settings.rfc2136_key_name: settings.rfc2136_key_secret})
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_update_records(update: Any, relative: str, result: UpdateResult, settings: DdnsSettings) -> None:
|
||||||
|
_apply_record_update(
|
||||||
|
update,
|
||||||
|
relative,
|
||||||
|
_RecordUpdate(ttl=settings.ttl, record_type="A", value=result.ipv4, enabled=result.update_ipv4),
|
||||||
|
)
|
||||||
|
_apply_record_update(
|
||||||
|
update,
|
||||||
|
relative,
|
||||||
|
_RecordUpdate(ttl=settings.ttl, record_type="AAAA", value=result.ipv6, enabled=result.update_ipv6),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_record_update(
|
||||||
|
update: Any,
|
||||||
|
relative: str,
|
||||||
|
record: _RecordUpdate,
|
||||||
|
) -> None:
|
||||||
|
if not record.enabled:
|
||||||
|
return
|
||||||
|
if record.value is None:
|
||||||
|
update.delete(relative, record.record_type)
|
||||||
|
return
|
||||||
|
update.replace(relative, record.ttl, record.record_type, record.value)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_rfc2136_update(update: Any, server: str, operation: str) -> None:
|
||||||
import dns.exception
|
import dns.exception
|
||||||
import dns.query
|
import dns.query
|
||||||
import dns.rcode
|
import dns.rcode
|
||||||
import dns.tsigkeyring
|
|
||||||
import dns.update
|
|
||||||
|
|
||||||
keyring = None
|
|
||||||
if settings.rfc2136_key_name and settings.rfc2136_key_secret:
|
|
||||||
keyring = dns.tsigkeyring.from_text({settings.rfc2136_key_name: settings.rfc2136_key_secret})
|
|
||||||
|
|
||||||
zone = settings.rfc2136_zone or ".".join(hostname.split(".")[1:])
|
|
||||||
update = dns.update.Update(zone, keyring=keyring)
|
|
||||||
relative = hostname.removesuffix("." + zone).rstrip(".") or "@"
|
|
||||||
update.delete(relative, "A")
|
|
||||||
update.delete(relative, "AAAA")
|
|
||||||
try:
|
try:
|
||||||
response = dns.query.tcp(update, settings.rfc2136_server, timeout=5)
|
response = dns.query.tcp(update, server, timeout=5)
|
||||||
except dns.exception.DNSException as exc:
|
except dns.exception.DNSException as exc:
|
||||||
raise DnsProviderError("RFC2136 DNS delete failed") from exc
|
raise DnsProviderError(f"RFC2136 DNS {operation} failed") from exc
|
||||||
|
_raise_for_rfc2136_response(response, operation)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_for_rfc2136_response(response: _RcodeResponse, operation: str) -> None:
|
||||||
|
import dns.rcode
|
||||||
|
|
||||||
rcode = response.rcode()
|
rcode = response.rcode()
|
||||||
if rcode != 0:
|
if rcode != 0:
|
||||||
raise DnsProviderError(f"RFC2136 delete failed: {dns.rcode.to_text(rcode)}")
|
raise DnsProviderError(f"RFC2136 {operation} failed: {dns.rcode.to_text(dns.rcode.Rcode(rcode))}")
|
||||||
+45
-50
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
@@ -18,72 +19,67 @@ from ..services.ddns import normalize_hostname
|
|||||||
from ..web.components import _copy_row
|
from ..web.components import _copy_row
|
||||||
from ..web.forms import first_form_value, request_form
|
from ..web.forms import first_form_value, request_form
|
||||||
from ..web.html_utils import SafeHtml
|
from ..web.html_utils import SafeHtml
|
||||||
from ..web.templates_admin import _admin_notice, _admin_secret_panel, _render_admin_page
|
from ..web.templates_admin import _admin_notice, _admin_page_context, _admin_secret_panel, _render_admin_page
|
||||||
|
|
||||||
ADMIN_TABLE_LIMIT = 100
|
ADMIN_TABLE_LIMIT = 100
|
||||||
ADMIN_JSON_OFFSET_CAP = 10_000
|
ADMIN_JSON_OFFSET_CAP = 10_000
|
||||||
|
AdminAuthDep = Annotated[None, Depends(authenticate_admin)]
|
||||||
|
AdminOffsetQuery = Annotated[int, Query(ge=0)]
|
||||||
|
CleanupDoneQuery = Annotated[bool, Query()]
|
||||||
|
CleanupCountQuery = Annotated[int, Query(ge=0)]
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_table_offsets(
|
||||||
|
accounts_offset: AdminOffsetQuery = 0,
|
||||||
|
cleanup_runs_offset: AdminOffsetQuery = 0,
|
||||||
|
update_events_offset: AdminOffsetQuery = 0,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"accounts": accounts_offset,
|
||||||
|
"cleanup_runs": cleanup_runs_offset,
|
||||||
|
"update_events": update_events_offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_cleanup_notice(
|
||||||
|
cleanup_done: CleanupDoneQuery = False,
|
||||||
|
cleanup_unused: CleanupCountQuery = 0,
|
||||||
|
cleanup_events: CleanupCountQuery = 0,
|
||||||
|
) -> SafeHtml | None:
|
||||||
|
if not cleanup_done:
|
||||||
|
return None
|
||||||
|
return _admin_notice(
|
||||||
|
"success",
|
||||||
|
f"Cleanup finished. Removed {cleanup_unused} hostnames and {cleanup_events} old events.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
AdminTableOffsetsDep = Annotated[dict[str, int], Depends(_admin_table_offsets)]
|
||||||
|
AdminCleanupNoticeDep = Annotated[SafeHtml | None, Depends(_admin_cleanup_notice)]
|
||||||
|
|
||||||
|
|
||||||
def make_router(admin_path: str = "/admin") -> APIRouter:
|
def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
def render_admin(
|
def render_admin(
|
||||||
service: ServiceDep,
|
|
||||||
settings: SettingsDep,
|
settings: SettingsDep,
|
||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
table_offsets: dict[str, int] | None = None,
|
table_offsets: dict[str, int] | None = None,
|
||||||
notice: SafeHtml | None = None,
|
notice: SafeHtml | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
table_offsets = table_offsets or {}
|
table_offsets = table_offsets or {}
|
||||||
return _render_admin_page(
|
context = _admin_page_context(store, table_offsets, ADMIN_TABLE_LIMIT, notice)
|
||||||
service,
|
return _render_admin_page(settings, context)
|
||||||
settings,
|
|
||||||
store.list_accounts(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("accounts", 0)),
|
|
||||||
store.list_cleanup_runs(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("cleanup_runs", 0)),
|
|
||||||
store.list_scheduled_tasks(),
|
|
||||||
store.list_update_events(ADMIN_TABLE_LIMIT, offset=table_offsets.get("update_events", 0)),
|
|
||||||
store.get_service_metrics(),
|
|
||||||
{
|
|
||||||
"accounts": store.count_accounts(),
|
|
||||||
"cleanup_runs": store.count_cleanup_runs(),
|
|
||||||
"update_events": store.count_update_events(),
|
|
||||||
},
|
|
||||||
table_offsets,
|
|
||||||
ADMIN_TABLE_LIMIT,
|
|
||||||
notice,
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.get(admin_path, response_class=HTMLResponse, include_in_schema=False)
|
@router.get(admin_path, response_class=HTMLResponse, include_in_schema=False)
|
||||||
def admin(
|
def admin(
|
||||||
settings: SettingsDep,
|
settings: SettingsDep,
|
||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
service: ServiceDep,
|
_: AdminAuthDep,
|
||||||
_: None = Depends(authenticate_admin),
|
table_offsets: AdminTableOffsetsDep,
|
||||||
accounts_offset: int = Query(0, ge=0),
|
notice: AdminCleanupNoticeDep,
|
||||||
cleanup_runs_offset: int = Query(0, ge=0),
|
|
||||||
update_events_offset: int = Query(0, ge=0),
|
|
||||||
cleanup_done: bool = Query(False),
|
|
||||||
cleanup_unused: int = Query(0, ge=0),
|
|
||||||
cleanup_events: int = Query(0, ge=0),
|
|
||||||
) -> str:
|
) -> str:
|
||||||
notice = (
|
return render_admin(settings, store, table_offsets, notice)
|
||||||
_admin_notice(
|
|
||||||
"success", f"Cleanup finished. Removed {cleanup_unused} hostnames and {cleanup_events} old events."
|
|
||||||
)
|
|
||||||
if cleanup_done
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
return render_admin(
|
|
||||||
service,
|
|
||||||
settings,
|
|
||||||
store,
|
|
||||||
{
|
|
||||||
"accounts": accounts_offset,
|
|
||||||
"cleanup_runs": cleanup_runs_offset,
|
|
||||||
"update_events": update_events_offset,
|
|
||||||
},
|
|
||||||
notice,
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.post(f"{admin_path}/hostnames/delete", include_in_schema=False)
|
@router.post(f"{admin_path}/hostnames/delete", include_in_schema=False)
|
||||||
async def admin_delete_account(
|
async def admin_delete_account(
|
||||||
@@ -91,7 +87,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
|||||||
settings: SettingsDep,
|
settings: SettingsDep,
|
||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
_: None = Depends(authenticate_admin),
|
_: AdminAuthDep,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
form = await request_form(request, settings)
|
form = await request_form(request, settings)
|
||||||
require_admin_csrf(settings, first_form_value(form, "csrf"))
|
require_admin_csrf(settings, first_form_value(form, "csrf"))
|
||||||
@@ -118,7 +114,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
|||||||
settings: SettingsDep,
|
settings: SettingsDep,
|
||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
_: None = Depends(authenticate_admin),
|
_: AdminAuthDep,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
form = await request_form(request, settings)
|
form = await request_form(request, settings)
|
||||||
require_admin_csrf(settings, first_form_value(form, "csrf"))
|
require_admin_csrf(settings, first_form_value(form, "csrf"))
|
||||||
@@ -168,7 +164,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="invalid rotation action")
|
raise HTTPException(status_code=400, detail="invalid rotation action")
|
||||||
page = await run_in_threadpool(render_admin, service, settings, store, None, notice)
|
page = await run_in_threadpool(render_admin, settings, store, None, notice)
|
||||||
return HTMLResponse(page)
|
return HTMLResponse(page)
|
||||||
|
|
||||||
@router.post(f"{admin_path}/cleanup", include_in_schema=False)
|
@router.post(f"{admin_path}/cleanup", include_in_schema=False)
|
||||||
@@ -176,8 +172,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
|||||||
request: Request,
|
request: Request,
|
||||||
settings: SettingsDep,
|
settings: SettingsDep,
|
||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
service: ServiceDep,
|
_: AdminAuthDep,
|
||||||
_: None = Depends(authenticate_admin),
|
|
||||||
) -> Response:
|
) -> Response:
|
||||||
form = await request_form(request, settings)
|
form = await request_form(request, settings)
|
||||||
require_admin_csrf(settings, first_form_value(form, "csrf"))
|
require_admin_csrf(settings, first_form_value(form, "csrf"))
|
||||||
|
|||||||
+29
-15
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
@@ -33,6 +35,12 @@ router = APIRouter(prefix="/api/v1")
|
|||||||
ADMIN_API_LIMIT_CAP = 500
|
ADMIN_API_LIMIT_CAP = 500
|
||||||
ADMIN_API_OFFSET_CAP = 10_000
|
ADMIN_API_OFFSET_CAP = 10_000
|
||||||
HOSTNAME_HISTORY_LIMIT_CAP = 100
|
HOSTNAME_HISTORY_LIMIT_CAP = 100
|
||||||
|
AdminAuthDep = Annotated[None, Depends(authenticate_admin)]
|
||||||
|
HostnameHistoryLimitQuery = Annotated[int, Query(ge=0, le=HOSTNAME_HISTORY_LIMIT_CAP)]
|
||||||
|
AdminLimitQuery = Annotated[int, Query(ge=1, le=ADMIN_API_LIMIT_CAP)]
|
||||||
|
AdminOffsetQuery = Annotated[int, Query(ge=0, le=ADMIN_API_OFFSET_CAP)]
|
||||||
|
AdminDashboardLimitQuery = Annotated[int, Query(ge=1, le=ADMIN_API_LIMIT_CAP)]
|
||||||
|
AdminBeforeQuery = Annotated[str | None, Query(max_length=64)]
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -52,7 +60,10 @@ async def create_hostname(service: ServiceDep) -> HostnameCreateResponse:
|
|||||||
"/hostnames/{dashboard_secret}",
|
"/hostnames/{dashboard_secret}",
|
||||||
response_model=HostnameStatusResponse,
|
response_model=HostnameStatusResponse,
|
||||||
summary="Read a hostname by private dashboard link",
|
summary="Read a hostname by private dashboard link",
|
||||||
description="Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
|
description=(
|
||||||
|
"Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, "
|
||||||
|
"not this API endpoint."
|
||||||
|
),
|
||||||
tags=["hostnames"],
|
tags=["hostnames"],
|
||||||
responses={404: {"model": ErrorResponse}},
|
responses={404: {"model": ErrorResponse}},
|
||||||
)
|
)
|
||||||
@@ -60,7 +71,7 @@ async def read_hostname(
|
|||||||
dashboard_secret: ManagementSlugPath,
|
dashboard_secret: ManagementSlugPath,
|
||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
history_limit: int = Query(10, ge=0, le=HOSTNAME_HISTORY_LIMIT_CAP),
|
history_limit: HostnameHistoryLimitQuery = 10,
|
||||||
) -> HostnameStatusResponse:
|
) -> HostnameStatusResponse:
|
||||||
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
|
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
|
||||||
if not account or account["disabled"]:
|
if not account or account["disabled"]:
|
||||||
@@ -113,7 +124,10 @@ async def rotate_hostname_update_url(
|
|||||||
"/hostnames/{dashboard_secret}",
|
"/hostnames/{dashboard_secret}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
summary="Delete a hostname by private dashboard link",
|
summary="Delete a hostname by private dashboard link",
|
||||||
description="Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
|
description=(
|
||||||
|
"Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, "
|
||||||
|
"not this API endpoint."
|
||||||
|
),
|
||||||
tags=["hostnames"],
|
tags=["hostnames"],
|
||||||
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
|
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
|
||||||
)
|
)
|
||||||
@@ -140,8 +154,8 @@ async def delete_hostname(dashboard_secret: ManagementSlugPath, store: StoreDep,
|
|||||||
)
|
)
|
||||||
async def read_admin(
|
async def read_admin(
|
||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
_: None = Depends(authenticate_admin),
|
_: AdminAuthDep,
|
||||||
limit: int = Query(25, ge=1, le=ADMIN_API_LIMIT_CAP),
|
limit: AdminDashboardLimitQuery = 25,
|
||||||
) -> AdminDashboardResponse:
|
) -> AdminDashboardResponse:
|
||||||
metrics = await run_in_threadpool(store.get_service_metrics)
|
metrics = await run_in_threadpool(store.get_service_metrics)
|
||||||
accounts = await run_in_threadpool(store.list_accounts, limit, 0)
|
accounts = await run_in_threadpool(store.list_accounts, limit, 0)
|
||||||
@@ -175,9 +189,9 @@ async def read_admin(
|
|||||||
)
|
)
|
||||||
async def list_admin_hostnames(
|
async def list_admin_hostnames(
|
||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
_: None = Depends(authenticate_admin),
|
_: AdminAuthDep,
|
||||||
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
|
limit: AdminLimitQuery = 100,
|
||||||
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
|
offset: AdminOffsetQuery = 0,
|
||||||
) -> AdminHostnamesResponse:
|
) -> AdminHostnamesResponse:
|
||||||
accounts = await run_in_threadpool(store.list_accounts, limit, offset)
|
accounts = await run_in_threadpool(store.list_accounts, limit, offset)
|
||||||
total = await run_in_threadpool(store.count_accounts)
|
total = await run_in_threadpool(store.count_accounts)
|
||||||
@@ -200,10 +214,10 @@ async def list_admin_hostnames(
|
|||||||
)
|
)
|
||||||
async def list_admin_events(
|
async def list_admin_events(
|
||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
_: None = Depends(authenticate_admin),
|
_: AdminAuthDep,
|
||||||
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
|
limit: AdminLimitQuery = 100,
|
||||||
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
|
offset: AdminOffsetQuery = 0,
|
||||||
before: str | None = Query(None, max_length=64),
|
before: AdminBeforeQuery = None,
|
||||||
) -> AdminEventsResponse:
|
) -> AdminEventsResponse:
|
||||||
items = (
|
items = (
|
||||||
await run_in_threadpool(store.list_update_events_before, limit, before)
|
await run_in_threadpool(store.list_update_events_before, limit, before)
|
||||||
@@ -232,9 +246,9 @@ async def list_admin_events(
|
|||||||
)
|
)
|
||||||
async def list_admin_records(
|
async def list_admin_records(
|
||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
_: None = Depends(authenticate_admin),
|
_: AdminAuthDep,
|
||||||
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
|
limit: AdminLimitQuery = 100,
|
||||||
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
|
offset: AdminOffsetQuery = 0,
|
||||||
) -> AdminRecordsResponse:
|
) -> AdminRecordsResponse:
|
||||||
items = await run_in_threadpool(store.list_records_page, limit, offset)
|
items = await run_in_threadpool(store.list_records_page, limit, offset)
|
||||||
total = await run_in_threadpool(store.count_records)
|
total = await run_in_threadpool(store.count_records)
|
||||||
|
|||||||
+12
-32
@@ -5,31 +5,11 @@ from pathlib import Path
|
|||||||
from fastapi import APIRouter, Response
|
from fastapi import APIRouter, Response
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
|
|
||||||
from ..web.scripts import ADMIN_TABLES_JS, CLIPBOARD_JS, THEME_JS, VISITOR_IP_JS
|
|
||||||
from ..web.styles import ADMIN_CSS, BASE_CSS
|
|
||||||
|
|
||||||
ASSET_DIR = Path(__file__).parents[1] / "assets"
|
ASSET_DIR = Path(__file__).parents[1] / "assets"
|
||||||
router = APIRouter(include_in_schema=False)
|
router = APIRouter(include_in_schema=False)
|
||||||
ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
|
ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
|
||||||
|
|
||||||
|
|
||||||
def _minify_css(css: str) -> str:
|
|
||||||
return (
|
|
||||||
" ".join(css.split())
|
|
||||||
.replace(" {", "{")
|
|
||||||
.replace("{ ", "{")
|
|
||||||
.replace("; ", ";")
|
|
||||||
.replace(": ", ":")
|
|
||||||
.replace(", ", ",")
|
|
||||||
.replace(" }", "}")
|
|
||||||
.replace(";}", "}")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
MINIFIED_BASE_CSS = _minify_css(BASE_CSS)
|
|
||||||
MINIFIED_ADMIN_CSS = _minify_css(ADMIN_CSS)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/favicon.ico")
|
@router.get("/favicon.ico")
|
||||||
def favicon() -> FileResponse:
|
def favicon() -> FileResponse:
|
||||||
return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS)
|
return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS)
|
||||||
@@ -115,33 +95,33 @@ def site_webmanifest() -> Response:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/theme.js")
|
@router.get("/theme.js")
|
||||||
def theme_js() -> Response:
|
def theme_js() -> FileResponse:
|
||||||
return Response(THEME_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
|
return FileResponse(ASSET_DIR / "theme.js", media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/clipboard.js")
|
@router.get("/clipboard.js")
|
||||||
def clipboard_js() -> Response:
|
def clipboard_js() -> FileResponse:
|
||||||
return Response(CLIPBOARD_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
|
return FileResponse(ASSET_DIR / "clipboard.js", media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin-tables.js")
|
@router.get("/admin-tables.js")
|
||||||
def admin_tables_js() -> Response:
|
def admin_tables_js() -> FileResponse:
|
||||||
return Response(ADMIN_TABLES_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
|
return FileResponse(ASSET_DIR / "admin-tables.js", media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/visitor-ip.js")
|
@router.get("/visitor-ip.js")
|
||||||
def visitor_ip_js() -> Response:
|
def visitor_ip_js() -> FileResponse:
|
||||||
return Response(VISITOR_IP_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
|
return FileResponse(ASSET_DIR / "visitor-ip.js", media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/app.css")
|
@router.get("/app.css")
|
||||||
def app_css() -> Response:
|
def app_css() -> FileResponse:
|
||||||
return Response(MINIFIED_BASE_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS)
|
return FileResponse(ASSET_DIR / "app.css", media_type="text/css", headers=ASSET_CACHE_HEADERS)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin.css")
|
@router.get("/admin.css")
|
||||||
def admin_css() -> Response:
|
def admin_css() -> FileResponse:
|
||||||
return Response(MINIFIED_ADMIN_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS)
|
return FileResponse(ASSET_DIR / "admin.css", media_type="text/css", headers=ASSET_CACHE_HEADERS)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/install.sh")
|
@router.get("/install.sh")
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import NoReturn
|
from typing import Annotated, NoReturn
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
@@ -14,11 +14,13 @@ from ..event_statuses import (
|
|||||||
DASHBOARD_UPDATE_URL_ROTATED,
|
DASHBOARD_UPDATE_URL_ROTATED,
|
||||||
)
|
)
|
||||||
from ..security import client_ip, dashboard_csrf_token, require_dashboard_csrf
|
from ..security import client_ip, dashboard_csrf_token, require_dashboard_csrf
|
||||||
from ..services.ddns import BadIpError
|
from ..services.ddns import BadIpError, UpdateIpParams
|
||||||
from ..web.forms import first_form_value, request_form
|
from ..web.forms import first_form_value, request_form
|
||||||
from ..web.html_utils import trusted
|
from ..web.html_utils import trusted
|
||||||
from ..web.layout import _page
|
from ..web.layout import _page
|
||||||
from ..web.templates_management import (
|
from ..web.templates_management import (
|
||||||
|
ManagementPageContext,
|
||||||
|
PendingManagementPageContext,
|
||||||
_render_management_page,
|
_render_management_page,
|
||||||
_render_management_unlock_page,
|
_render_management_unlock_page,
|
||||||
_render_pending_management_page,
|
_render_pending_management_page,
|
||||||
@@ -36,6 +38,19 @@ from .path_params import ManagementSlugPath
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(management_passwords.router)
|
router.include_router(management_passwords.router)
|
||||||
|
ActivationIpQuery = Annotated[str | None, Query()]
|
||||||
|
|
||||||
|
|
||||||
|
def _activation_ip_params_from_query(
|
||||||
|
myip: ActivationIpQuery = None,
|
||||||
|
ipaddr: ActivationIpQuery = None,
|
||||||
|
myipv6: ActivationIpQuery = None,
|
||||||
|
ip6addr: ActivationIpQuery = None,
|
||||||
|
) -> UpdateIpParams:
|
||||||
|
return UpdateIpParams(myip=myip, ipaddr=ipaddr, myipv6=myipv6, ip6addr=ip6addr)
|
||||||
|
|
||||||
|
|
||||||
|
ActivationIpParamsDep = Annotated[UpdateIpParams, Depends(_activation_ip_params_from_query)]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/m/{management_slug}", response_class=HTMLResponse, include_in_schema=False)
|
@router.get("/m/{management_slug}", response_class=HTMLResponse, include_in_schema=False)
|
||||||
@@ -43,27 +58,34 @@ def manage_dashboard(
|
|||||||
management_slug: ManagementSlugPath,
|
management_slug: ManagementSlugPath,
|
||||||
request: Request,
|
request: Request,
|
||||||
settings: SettingsDep,
|
settings: SettingsDep,
|
||||||
store: StoreDep,
|
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
activated: bool = False,
|
activated: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
account = get_active_management_account(store, management_slug)
|
account = get_active_management_account(service.store, management_slug)
|
||||||
csrf_token = dashboard_csrf_token(management_slug, account)
|
csrf_token = dashboard_csrf_token(management_slug, account)
|
||||||
if not management_account_is_accepted(account):
|
if not management_account_is_accepted(account):
|
||||||
return _render_pending_management_page(
|
return _render_pending_management_page(
|
||||||
service, settings, management_slug, account, csrf_token, request_ip=client_ip(request, settings)
|
service,
|
||||||
|
PendingManagementPageContext(
|
||||||
|
settings=settings,
|
||||||
|
management_slug=management_slug,
|
||||||
|
account=account,
|
||||||
|
csrf_token=csrf_token,
|
||||||
|
request_ip=client_ip(request, settings),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if not has_management_access(request, settings, management_slug, account):
|
if not has_management_access(request, settings, management_slug, account):
|
||||||
return _render_management_unlock_page(management_slug, account, csrf_token)
|
return _render_management_unlock_page(management_slug, account, csrf_token)
|
||||||
message = "Hostname registered. Add the update URL to your router." if activated else None
|
message = "Hostname registered. Add the update URL to your router." if activated else None
|
||||||
return _render_management_page(
|
return _render_management_page(
|
||||||
service,
|
service,
|
||||||
settings,
|
ManagementPageContext(
|
||||||
management_slug,
|
management_slug=management_slug,
|
||||||
account,
|
account=account,
|
||||||
csrf_token,
|
csrf_token=csrf_token,
|
||||||
message=message,
|
message=message,
|
||||||
request_ip=client_ip(request, settings),
|
request_ip=client_ip(request, settings),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -72,30 +94,36 @@ async def activate_dashboard(
|
|||||||
management_slug: ManagementSlugPath,
|
management_slug: ManagementSlugPath,
|
||||||
request: Request,
|
request: Request,
|
||||||
settings: SettingsDep,
|
settings: SettingsDep,
|
||||||
store: StoreDep,
|
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
myip: str | None = None,
|
ip_params: ActivationIpParamsDep,
|
||||||
ipaddr: str | None = None,
|
|
||||||
myipv6: str | None = None,
|
|
||||||
ip6addr: str | None = None,
|
|
||||||
) -> RedirectResponse | HTMLResponse:
|
) -> RedirectResponse | HTMLResponse:
|
||||||
account = await get_active_management_account_async(store, management_slug)
|
account = await get_active_management_account_async(service.store, management_slug)
|
||||||
form = await request_form(request, settings)
|
form = await request_form(request, settings)
|
||||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||||
hostname = str(account["hostname"])
|
hostname = str(account["hostname"])
|
||||||
if not management_account_is_accepted(account):
|
if not management_account_is_accepted(account):
|
||||||
try:
|
try:
|
||||||
await run_in_threadpool(service.publish_initial_dns, request, hostname, myip, ipaddr, myipv6, ip6addr)
|
await run_in_threadpool(
|
||||||
|
service.publish_initial_dns,
|
||||||
|
request,
|
||||||
|
hostname,
|
||||||
|
ip_params,
|
||||||
|
)
|
||||||
except BadIpError:
|
except BadIpError:
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
_render_pending_management_page(
|
_render_pending_management_page(
|
||||||
service,
|
service,
|
||||||
settings,
|
PendingManagementPageContext(
|
||||||
management_slug,
|
settings=settings,
|
||||||
account,
|
management_slug=management_slug,
|
||||||
dashboard_csrf_token(management_slug, account),
|
account=account,
|
||||||
"The supplied IP address is invalid. Leave IP fields empty and let the router send the first update.",
|
csrf_token=dashboard_csrf_token(management_slug, account),
|
||||||
client_ip(request, settings),
|
message=(
|
||||||
|
"The supplied IP address is invalid. Leave IP fields empty and let the router "
|
||||||
|
"send the first update."
|
||||||
|
),
|
||||||
|
request_ip=client_ip(request, settings),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
@@ -103,20 +131,22 @@ async def activate_dashboard(
|
|||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
_render_pending_management_page(
|
_render_pending_management_page(
|
||||||
service,
|
service,
|
||||||
settings,
|
PendingManagementPageContext(
|
||||||
management_slug,
|
settings=settings,
|
||||||
account,
|
management_slug=management_slug,
|
||||||
dashboard_csrf_token(management_slug, account),
|
account=account,
|
||||||
"DNS registration failed. The hostname was not registered.",
|
csrf_token=dashboard_csrf_token(management_slug, account),
|
||||||
client_ip(request, settings),
|
message="DNS registration failed. The hostname was not registered.",
|
||||||
|
request_ip=client_ip(request, settings),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
status_code=500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
activated_account = await run_in_threadpool(lambda: store.activate_account(hostname))
|
activated_account = await run_in_threadpool(lambda: service.store.activate_account(hostname))
|
||||||
if not activated_account:
|
if not activated_account:
|
||||||
return _not_found()
|
return _not_found()
|
||||||
await run_in_threadpool(
|
await run_in_threadpool(
|
||||||
store.log_update_event,
|
service.store.log_update_event,
|
||||||
hostname,
|
hostname,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -140,12 +170,14 @@ async def rotate_dashboard_secrets(
|
|||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
_render_pending_management_page(
|
_render_pending_management_page(
|
||||||
service,
|
service,
|
||||||
settings,
|
PendingManagementPageContext(
|
||||||
management_slug,
|
settings=settings,
|
||||||
account,
|
management_slug=management_slug,
|
||||||
dashboard_csrf_token(management_slug, account),
|
account=account,
|
||||||
"Register this hostname first.",
|
csrf_token=dashboard_csrf_token(management_slug, account),
|
||||||
client_ip(request, settings),
|
message="Register this hostname first.",
|
||||||
|
request_ip=client_ip(request, settings),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
@@ -171,12 +203,13 @@ async def rotate_dashboard_secrets(
|
|||||||
)
|
)
|
||||||
return await render_management_page_async(
|
return await render_management_page_async(
|
||||||
service,
|
service,
|
||||||
settings,
|
ManagementPageContext(
|
||||||
management_slug,
|
management_slug=management_slug,
|
||||||
rotated,
|
account=rotated,
|
||||||
dashboard_csrf_token(management_slug, rotated),
|
csrf_token=dashboard_csrf_token(management_slug, rotated),
|
||||||
message="Update URL changed. Update your router now.",
|
message="Update URL changed. Update your router now.",
|
||||||
request_ip=request_ip,
|
request_ip=request_ip,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
raise _bad_rotation_action()
|
raise _bad_rotation_action()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Response
|
from fastapi import APIRouter, Request, Response
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
@@ -7,9 +10,11 @@ from starlette.concurrency import run_in_threadpool
|
|||||||
from ..dependencies import ServiceDep, SettingsDep, StoreDep
|
from ..dependencies import ServiceDep, SettingsDep, StoreDep
|
||||||
from ..event_statuses import DASHBOARD_PASSWORD_CHANGED, DASHBOARD_PASSWORD_ENABLED, DASHBOARD_PASSWORD_REMOVED
|
from ..event_statuses import DASHBOARD_PASSWORD_CHANGED, DASHBOARD_PASSWORD_ENABLED, DASHBOARD_PASSWORD_REMOVED
|
||||||
from ..security import client_ip, dashboard_csrf_token, require_dashboard_csrf
|
from ..security import client_ip, dashboard_csrf_token, require_dashboard_csrf
|
||||||
|
from ..store import DdnsStore
|
||||||
from ..web.forms import first_form_value, request_form
|
from ..web.forms import first_form_value, request_form
|
||||||
from ..web.templates_management import _render_management_unlock_page
|
from ..web.templates_management import ManagementPageContext, _render_management_unlock_page
|
||||||
from .management_shared import (
|
from .management_shared import (
|
||||||
|
ManagementAccount,
|
||||||
delete_unlock_cookie,
|
delete_unlock_cookie,
|
||||||
get_active_management_account_async,
|
get_active_management_account_async,
|
||||||
has_management_access,
|
has_management_access,
|
||||||
@@ -23,6 +28,15 @@ from .path_params import ManagementSlugPath
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _DashboardPasswordErrorContext:
|
||||||
|
management_slug: str
|
||||||
|
account: ManagementAccount
|
||||||
|
form: dict[str, list[str]]
|
||||||
|
request_ip: str
|
||||||
|
current_password_error: str | None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/m/{management_slug}/unlock", include_in_schema=False)
|
@router.post("/m/{management_slug}/unlock", include_in_schema=False)
|
||||||
async def unlock_dashboard(
|
async def unlock_dashboard(
|
||||||
management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep
|
management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep
|
||||||
@@ -70,45 +84,21 @@ async def set_dashboard_password(
|
|||||||
form = await request_form(request, settings)
|
form = await request_form(request, settings)
|
||||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||||
hostname = str(account["hostname"])
|
hostname = str(account["hostname"])
|
||||||
if account.get("dashboard_password_hash"):
|
current_password_error = await _verify_current_dashboard_password(store, hostname, account, form)
|
||||||
is_current = await run_in_threadpool(
|
request_ip = client_ip(request, settings)
|
||||||
store.verify_dashboard_password,
|
error_response = await _dashboard_password_error_response(
|
||||||
hostname,
|
service,
|
||||||
first_form_value(form, "current_dashboard_password"),
|
_DashboardPasswordErrorContext(
|
||||||
)
|
management_slug=management_slug,
|
||||||
if not is_current:
|
account=account,
|
||||||
return await management_error_response_async(
|
form=form,
|
||||||
service,
|
request_ip=request_ip,
|
||||||
settings,
|
current_password_error=current_password_error,
|
||||||
management_slug,
|
),
|
||||||
account,
|
)
|
||||||
"Current password is incorrect.",
|
if error_response:
|
||||||
client_ip(request, settings),
|
return error_response
|
||||||
)
|
|
||||||
password = first_form_value(form, "dashboard_password")
|
password = first_form_value(form, "dashboard_password")
|
||||||
confirm = first_form_value(form, "dashboard_password_confirm")
|
|
||||||
if len(password) < 8:
|
|
||||||
page = await render_management_page_async(
|
|
||||||
service,
|
|
||||||
settings,
|
|
||||||
management_slug,
|
|
||||||
account,
|
|
||||||
dashboard_csrf_token(management_slug, account),
|
|
||||||
message="Password must be at least 8 characters.",
|
|
||||||
request_ip=client_ip(request, settings),
|
|
||||||
)
|
|
||||||
return HTMLResponse(page, status_code=400)
|
|
||||||
if password != confirm:
|
|
||||||
page = await render_management_page_async(
|
|
||||||
service,
|
|
||||||
settings,
|
|
||||||
management_slug,
|
|
||||||
account,
|
|
||||||
dashboard_csrf_token(management_slug, account),
|
|
||||||
message="The passwords do not match.",
|
|
||||||
request_ip=client_ip(request, settings),
|
|
||||||
)
|
|
||||||
return HTMLResponse(page, status_code=400)
|
|
||||||
had_password = bool(account.get("dashboard_password_hash"))
|
had_password = bool(account.get("dashboard_password_hash"))
|
||||||
await run_in_threadpool(store.set_dashboard_password, hostname, password)
|
await run_in_threadpool(store.set_dashboard_password, hostname, password)
|
||||||
account = await get_active_management_account_async(store, management_slug)
|
account = await get_active_management_account_async(store, management_slug)
|
||||||
@@ -126,6 +116,61 @@ async def set_dashboard_password(
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _dashboard_password_error(password: str, confirm: str) -> str | None:
|
||||||
|
if len(password) < 8:
|
||||||
|
return "Password must be at least 8 characters."
|
||||||
|
if password != confirm:
|
||||||
|
return "The passwords do not match."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _dashboard_password_error_response(
|
||||||
|
service: ServiceDep,
|
||||||
|
context: _DashboardPasswordErrorContext,
|
||||||
|
) -> HTMLResponse | None:
|
||||||
|
if context.current_password_error:
|
||||||
|
return await management_error_response_async(
|
||||||
|
service,
|
||||||
|
context.management_slug,
|
||||||
|
context.account,
|
||||||
|
context.current_password_error,
|
||||||
|
context.request_ip,
|
||||||
|
)
|
||||||
|
password_error = _dashboard_password_error(
|
||||||
|
first_form_value(context.form, "dashboard_password"),
|
||||||
|
first_form_value(context.form, "dashboard_password_confirm"),
|
||||||
|
)
|
||||||
|
if password_error is None:
|
||||||
|
return None
|
||||||
|
page = await render_management_page_async(
|
||||||
|
service,
|
||||||
|
ManagementPageContext(
|
||||||
|
management_slug=context.management_slug,
|
||||||
|
account=context.account,
|
||||||
|
csrf_token=dashboard_csrf_token(context.management_slug, context.account),
|
||||||
|
message=password_error,
|
||||||
|
request_ip=context.request_ip,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return HTMLResponse(page, status_code=400)
|
||||||
|
|
||||||
|
|
||||||
|
async def _verify_current_dashboard_password(
|
||||||
|
store: DdnsStore,
|
||||||
|
hostname: str,
|
||||||
|
account: Mapping[str, object],
|
||||||
|
form: dict[str, list[str]],
|
||||||
|
) -> str | None:
|
||||||
|
if not account.get("dashboard_password_hash"):
|
||||||
|
return None
|
||||||
|
is_current = await run_in_threadpool(
|
||||||
|
store.verify_dashboard_password,
|
||||||
|
hostname,
|
||||||
|
first_form_value(form, "current_dashboard_password"),
|
||||||
|
)
|
||||||
|
return None if is_current else "Current password is incorrect."
|
||||||
|
|
||||||
|
|
||||||
@router.post("/m/{management_slug}/password/remove", include_in_schema=False)
|
@router.post("/m/{management_slug}/password/remove", include_in_schema=False)
|
||||||
async def remove_dashboard_password(
|
async def remove_dashboard_password(
|
||||||
management_slug: ManagementSlugPath,
|
management_slug: ManagementSlugPath,
|
||||||
@@ -152,7 +197,6 @@ async def remove_dashboard_password(
|
|||||||
if not is_current:
|
if not is_current:
|
||||||
return await management_error_response_async(
|
return await management_error_response_async(
|
||||||
service,
|
service,
|
||||||
settings,
|
|
||||||
management_slug,
|
management_slug,
|
||||||
account,
|
account,
|
||||||
"Current password is incorrect.",
|
"Current password is incorrect.",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from starlette.concurrency import run_in_threadpool
|
|||||||
|
|
||||||
from ..security import (
|
from ..security import (
|
||||||
DASHBOARD_UNLOCK_COOKIE,
|
DASHBOARD_UNLOCK_COOKIE,
|
||||||
|
DashboardUnlockIdentity,
|
||||||
dashboard_cookie_signing_secret,
|
dashboard_cookie_signing_secret,
|
||||||
dashboard_csrf_token,
|
dashboard_csrf_token,
|
||||||
sign_dashboard_unlock_cookie,
|
sign_dashboard_unlock_cookie,
|
||||||
@@ -15,7 +16,7 @@ from ..services.ddns import DdnsService
|
|||||||
from ..settings import DdnsSettings
|
from ..settings import DdnsSettings
|
||||||
from ..store import DdnsStore
|
from ..store import DdnsStore
|
||||||
from ..store.types import AccountRow
|
from ..store.types import AccountRow
|
||||||
from ..web.templates_management import _render_management_page, _render_management_unlock_page
|
from ..web.templates_management import ManagementPageContext, _render_management_page, _render_management_unlock_page
|
||||||
|
|
||||||
ManagementAccount = AccountRow
|
ManagementAccount = AccountRow
|
||||||
|
|
||||||
@@ -59,7 +60,6 @@ def locked_management_response(management_slug: str, account: ManagementAccount)
|
|||||||
|
|
||||||
def management_error_response(
|
def management_error_response(
|
||||||
service: DdnsService,
|
service: DdnsService,
|
||||||
settings: DdnsSettings,
|
|
||||||
management_slug: str,
|
management_slug: str,
|
||||||
account: ManagementAccount,
|
account: ManagementAccount,
|
||||||
message: str,
|
message: str,
|
||||||
@@ -68,12 +68,13 @@ def management_error_response(
|
|||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
_render_management_page(
|
_render_management_page(
|
||||||
service,
|
service,
|
||||||
settings,
|
ManagementPageContext(
|
||||||
management_slug,
|
management_slug=management_slug,
|
||||||
account,
|
account=account,
|
||||||
dashboard_csrf_token(management_slug, account),
|
csrf_token=dashboard_csrf_token(management_slug, account),
|
||||||
message=message,
|
message=message,
|
||||||
request_ip=request_ip,
|
request_ip=request_ip,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
@@ -81,35 +82,22 @@ def management_error_response(
|
|||||||
|
|
||||||
async def management_error_response_async(
|
async def management_error_response_async(
|
||||||
service: DdnsService,
|
service: DdnsService,
|
||||||
settings: DdnsSettings,
|
|
||||||
management_slug: str,
|
management_slug: str,
|
||||||
account: ManagementAccount,
|
account: ManagementAccount,
|
||||||
message: str,
|
message: str,
|
||||||
request_ip: str | None = None,
|
request_ip: str | None = None,
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
return await run_in_threadpool(
|
return await run_in_threadpool(management_error_response, service, management_slug, account, message, request_ip)
|
||||||
management_error_response, service, settings, management_slug, account, message, request_ip
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def render_management_page_async(
|
async def render_management_page_async(
|
||||||
service: DdnsService,
|
service: DdnsService,
|
||||||
settings: DdnsSettings,
|
context: ManagementPageContext,
|
||||||
management_slug: str,
|
|
||||||
account: ManagementAccount,
|
|
||||||
csrf_token: str,
|
|
||||||
message: str | None = None,
|
|
||||||
request_ip: str | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
return await run_in_threadpool(
|
return await run_in_threadpool(
|
||||||
_render_management_page,
|
_render_management_page,
|
||||||
service,
|
service,
|
||||||
settings,
|
context,
|
||||||
management_slug,
|
|
||||||
account,
|
|
||||||
csrf_token,
|
|
||||||
message,
|
|
||||||
request_ip,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -127,9 +115,7 @@ def set_unlock_cookie(
|
|||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
DASHBOARD_UNLOCK_COOKIE,
|
DASHBOARD_UNLOCK_COOKIE,
|
||||||
sign_dashboard_unlock_cookie(
|
sign_dashboard_unlock_cookie(
|
||||||
str(account["hostname"]),
|
DashboardUnlockIdentity(str(account["hostname"]), management_slug, str(password_hash)),
|
||||||
management_slug,
|
|
||||||
str(password_hash),
|
|
||||||
signing_secret,
|
signing_secret,
|
||||||
max_age_seconds,
|
max_age_seconds,
|
||||||
),
|
),
|
||||||
|
|||||||
+136
-86
@@ -1,20 +1,36 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Annotated
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from ..dependencies import ServiceDep, StoreDep, apply_update_response
|
from ..dependencies import ServiceDep, apply_update_response
|
||||||
from ..dns import DnsProviderError
|
from ..dns import DnsProviderError
|
||||||
from ..event_statuses import DASHBOARD_HOSTNAME_ACTIVATED
|
from ..event_statuses import DASHBOARD_HOSTNAME_ACTIVATED
|
||||||
from ..security import client_ip
|
from ..security import client_ip
|
||||||
from ..services.ddns import BadIpError
|
from ..services.ddns import BadIpError, DdnsService, UpdateIpParams
|
||||||
from .path_params import ManagementSlugPath, UpdateSlugPath
|
from .path_params import ManagementSlugPath, UpdateSlugPath
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
RouterIpQuery = Annotated[str | None, Query()]
|
||||||
|
_IpParams = UpdateIpParams
|
||||||
|
|
||||||
|
|
||||||
|
def _ip_params_from_query(
|
||||||
|
myip: RouterIpQuery = None,
|
||||||
|
ipaddr: RouterIpQuery = None,
|
||||||
|
myipv6: RouterIpQuery = None,
|
||||||
|
ip6addr: RouterIpQuery = None,
|
||||||
|
) -> UpdateIpParams:
|
||||||
|
return UpdateIpParams(myip=myip, ipaddr=ipaddr, myipv6=myipv6, ip6addr=ip6addr)
|
||||||
|
|
||||||
|
|
||||||
|
RouterIpParamsDep = Annotated[UpdateIpParams, Depends(_ip_params_from_query)]
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -27,14 +43,15 @@ router = APIRouter()
|
|||||||
async def register_hostname_get(
|
async def register_hostname_get(
|
||||||
dashboard_secret: ManagementSlugPath,
|
dashboard_secret: ManagementSlugPath,
|
||||||
request: Request,
|
request: Request,
|
||||||
store: StoreDep,
|
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
myip: str | None = Query(default=None),
|
ip_params: RouterIpParamsDep,
|
||||||
ipaddr: str | None = Query(default=None),
|
|
||||||
myipv6: str | None = Query(default=None),
|
|
||||||
ip6addr: str | None = Query(default=None),
|
|
||||||
) -> str | PlainTextResponse:
|
) -> str | PlainTextResponse:
|
||||||
return await _register_hostname(dashboard_secret, request, store, service, myip, ipaddr, myipv6, ip6addr)
|
return await _register_hostname(
|
||||||
|
dashboard_secret,
|
||||||
|
request,
|
||||||
|
service,
|
||||||
|
ip_params,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -47,45 +64,76 @@ async def register_hostname_get(
|
|||||||
async def register_hostname_post(
|
async def register_hostname_post(
|
||||||
dashboard_secret: ManagementSlugPath,
|
dashboard_secret: ManagementSlugPath,
|
||||||
request: Request,
|
request: Request,
|
||||||
store: StoreDep,
|
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
myip: str | None = Query(default=None),
|
ip_params: RouterIpParamsDep,
|
||||||
ipaddr: str | None = Query(default=None),
|
|
||||||
myipv6: str | None = Query(default=None),
|
|
||||||
ip6addr: str | None = Query(default=None),
|
|
||||||
) -> str | PlainTextResponse:
|
) -> str | PlainTextResponse:
|
||||||
return await _register_hostname(dashboard_secret, request, store, service, myip, ipaddr, myipv6, ip6addr)
|
return await _register_hostname(
|
||||||
|
dashboard_secret,
|
||||||
|
request,
|
||||||
|
service,
|
||||||
|
ip_params,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _register_hostname(
|
async def _register_hostname(
|
||||||
dashboard_secret: str,
|
dashboard_secret: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
store: StoreDep,
|
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
myip: str | None,
|
ip_params: _IpParams,
|
||||||
ipaddr: str | None,
|
|
||||||
myipv6: str | None,
|
|
||||||
ip6addr: str | None,
|
|
||||||
) -> str | PlainTextResponse:
|
) -> str | PlainTextResponse:
|
||||||
myip, ipaddr, myipv6, ip6addr = await _ip_params_from_request(request, myip, ipaddr, myipv6, ip6addr)
|
ip_params = await _ip_params_from_request(request, ip_params)
|
||||||
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
|
account = await run_in_threadpool(service.store.get_account_by_management_slug, dashboard_secret)
|
||||||
if not account or account["disabled"]:
|
hostname = _registration_hostname(account, service)
|
||||||
|
assert account is not None
|
||||||
|
dns_error = await _publish_initial_registration_dns(request, service, account, hostname, ip_params)
|
||||||
|
if dns_error is not None:
|
||||||
|
return dns_error
|
||||||
|
activated = await run_in_threadpool(service.store.activate_account, hostname)
|
||||||
|
if not activated:
|
||||||
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
await _log_registration_activation(request, service, hostname)
|
||||||
|
return "good registered"
|
||||||
|
|
||||||
|
|
||||||
|
def _registration_hostname(account: Mapping[str, object] | None, service: DdnsService) -> str:
|
||||||
|
if not account or account.get("disabled"):
|
||||||
raise HTTPException(status_code=404, detail="not found")
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
hostname = str(account["hostname"])
|
hostname = str(account["hostname"])
|
||||||
if not service.hostname_is_managed(hostname):
|
if not service.hostname_is_managed(hostname):
|
||||||
raise HTTPException(status_code=404, detail="not found")
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
if not account["accepted_at"]:
|
return hostname
|
||||||
try:
|
|
||||||
await run_in_threadpool(service.publish_initial_dns, request, hostname, myip, ipaddr, myipv6, ip6addr)
|
|
||||||
except BadIpError as exc:
|
async def _publish_initial_registration_dns(
|
||||||
return PlainTextResponse(f"badip {exc}", status_code=400)
|
request: Request,
|
||||||
except DnsProviderError:
|
service: DdnsService,
|
||||||
return PlainTextResponse("911 dns registration failed", status_code=500)
|
account: Mapping[str, object],
|
||||||
activated = await run_in_threadpool(store.activate_account, hostname)
|
hostname: str,
|
||||||
if not activated:
|
ip_params: _IpParams,
|
||||||
raise HTTPException(status_code=404, detail="not found")
|
) -> PlainTextResponse | None:
|
||||||
|
if account.get("accepted_at"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
await run_in_threadpool(
|
||||||
|
service.publish_initial_dns,
|
||||||
|
request,
|
||||||
|
hostname,
|
||||||
|
ip_params,
|
||||||
|
)
|
||||||
|
except BadIpError as exc:
|
||||||
|
return PlainTextResponse(f"badip {exc}", status_code=400)
|
||||||
|
except DnsProviderError:
|
||||||
|
return PlainTextResponse("911 dns registration failed", status_code=500)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _log_registration_activation(
|
||||||
|
request: Request,
|
||||||
|
service: DdnsService,
|
||||||
|
hostname: str,
|
||||||
|
) -> None:
|
||||||
await run_in_threadpool(
|
await run_in_threadpool(
|
||||||
store.log_update_event,
|
service.store.log_update_event,
|
||||||
hostname,
|
hostname,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -93,7 +141,6 @@ async def _register_hostname(
|
|||||||
"hostname registered",
|
"hostname registered",
|
||||||
client_ip(request, service.settings),
|
client_ip(request, service.settings),
|
||||||
)
|
)
|
||||||
return "good registered"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -106,14 +153,15 @@ async def _register_hostname(
|
|||||||
async def update_hostname_get(
|
async def update_hostname_get(
|
||||||
update_slug: UpdateSlugPath,
|
update_slug: UpdateSlugPath,
|
||||||
request: Request,
|
request: Request,
|
||||||
store: StoreDep,
|
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
myip: str | None = Query(default=None),
|
ip_params: RouterIpParamsDep,
|
||||||
ipaddr: str | None = Query(default=None),
|
|
||||||
myipv6: str | None = Query(default=None),
|
|
||||||
ip6addr: str | None = Query(default=None),
|
|
||||||
) -> str | PlainTextResponse:
|
) -> str | PlainTextResponse:
|
||||||
return await _update_hostname(update_slug, request, store, service, myip, ipaddr, myipv6, ip6addr)
|
return await _update_hostname(
|
||||||
|
update_slug,
|
||||||
|
request,
|
||||||
|
service,
|
||||||
|
ip_params,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -126,76 +174,78 @@ async def update_hostname_get(
|
|||||||
async def update_hostname_post(
|
async def update_hostname_post(
|
||||||
update_slug: UpdateSlugPath,
|
update_slug: UpdateSlugPath,
|
||||||
request: Request,
|
request: Request,
|
||||||
store: StoreDep,
|
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
myip: str | None = Query(default=None),
|
ip_params: RouterIpParamsDep,
|
||||||
ipaddr: str | None = Query(default=None),
|
|
||||||
myipv6: str | None = Query(default=None),
|
|
||||||
ip6addr: str | None = Query(default=None),
|
|
||||||
) -> str | PlainTextResponse:
|
) -> str | PlainTextResponse:
|
||||||
return await _update_hostname(update_slug, request, store, service, myip, ipaddr, myipv6, ip6addr)
|
return await _update_hostname(
|
||||||
|
update_slug,
|
||||||
|
request,
|
||||||
|
service,
|
||||||
|
ip_params,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _update_hostname(
|
async def _update_hostname(
|
||||||
update_slug: str,
|
update_slug: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
store: StoreDep,
|
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
myip: str | None,
|
ip_params: _IpParams,
|
||||||
ipaddr: str | None,
|
|
||||||
myipv6: str | None,
|
|
||||||
ip6addr: str | None,
|
|
||||||
) -> str | PlainTextResponse:
|
) -> str | PlainTextResponse:
|
||||||
myip, ipaddr, myipv6, ip6addr = await _ip_params_from_request(request, myip, ipaddr, myipv6, ip6addr)
|
ip_params = await _ip_params_from_request(request, ip_params)
|
||||||
account = await run_in_threadpool(store.get_account_by_slug, update_slug)
|
account = await run_in_threadpool(service.store.get_account_by_slug, update_slug)
|
||||||
if not account or account["disabled"] or not account["accepted_at"]:
|
if not account or account["disabled"] or not account["accepted_at"]:
|
||||||
raise HTTPException(status_code=404, detail="not found")
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
if not service.hostname_is_managed(str(account["hostname"])):
|
if not service.hostname_is_managed(str(account["hostname"])):
|
||||||
raise HTTPException(status_code=404, detail="not found")
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
return await run_in_threadpool(
|
return await run_in_threadpool(
|
||||||
apply_update_response, service, request, str(account["hostname"]), myip, ipaddr, myipv6, ip6addr
|
apply_update_response,
|
||||||
|
service,
|
||||||
|
request,
|
||||||
|
str(account["hostname"]),
|
||||||
|
ip_params,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _ip_params_from_request(
|
async def _ip_params_from_request(request: Request, ip_params: _IpParams) -> _IpParams:
|
||||||
request: Request,
|
|
||||||
myip: str | None,
|
|
||||||
ipaddr: str | None,
|
|
||||||
myipv6: str | None,
|
|
||||||
ip6addr: str | None,
|
|
||||||
) -> tuple[str | None, str | None, str | None, str | None]:
|
|
||||||
if request.method != "POST":
|
if request.method != "POST":
|
||||||
return myip, ipaddr, myipv6, ip6addr
|
return ip_params
|
||||||
body_values = await _post_values(request)
|
body_values = await _post_values(request)
|
||||||
return (
|
return ip_params.with_body_values(body_values)
|
||||||
myip or body_values.get("myip"),
|
|
||||||
ipaddr or body_values.get("ipaddr"),
|
|
||||||
myipv6 or body_values.get("myipv6"),
|
|
||||||
ip6addr or body_values.get("ip6addr"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _post_values(request: Request) -> dict[str, str]:
|
async def _post_values(request: Request) -> dict[str, str]:
|
||||||
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
if not body:
|
if not body:
|
||||||
return {}
|
return {}
|
||||||
|
content_type = _request_content_type(request)
|
||||||
if content_type == "application/json":
|
if content_type == "application/json":
|
||||||
try:
|
return _json_post_values(body)
|
||||||
data = json.loads(body)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return {}
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return {}
|
|
||||||
return {str(key): str(value) for key, value in data.items() if isinstance(value, str)}
|
|
||||||
if content_type == "application/x-www-form-urlencoded":
|
if content_type == "application/x-www-form-urlencoded":
|
||||||
try:
|
return _form_post_values(body)
|
||||||
form_body = body.decode("utf-8", errors="strict")
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
parsed = parse_qs(form_body, keep_blank_values=True, max_num_fields=8)
|
|
||||||
except ValueError:
|
|
||||||
return {}
|
|
||||||
return {key: values[0] for key, values in parsed.items() if values}
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _request_content_type(request: Request) -> str:
|
||||||
|
return request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _json_post_values(body: bytes) -> dict[str, str]:
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {}
|
||||||
|
return {str(key): value for key, value in data.items() if isinstance(value, str)}
|
||||||
|
|
||||||
|
|
||||||
|
def _form_post_values(body: bytes) -> dict[str, str]:
|
||||||
|
try:
|
||||||
|
form_body = body.decode("utf-8", errors="strict")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
parsed = parse_qs(form_body, keep_blank_values=True, max_num_fields=8)
|
||||||
|
except ValueError:
|
||||||
|
return {}
|
||||||
|
return {key: values[0] for key, values in parsed.items() if values}
|
||||||
+42
-24
@@ -153,16 +153,14 @@ def _hostname_status(
|
|||||||
account: Mapping[str, object],
|
account: Mapping[str, object],
|
||||||
history: Sequence[Mapping[str, object]] | None = None,
|
history: Sequence[Mapping[str, object]] | None = None,
|
||||||
) -> HostnameStatusResponse:
|
) -> HostnameStatusResponse:
|
||||||
account_for_url = {"update_slug": str(account["update_slug"])}
|
|
||||||
account_for_dashboard = {"management_slug": str(account.get("management_slug", ""))}
|
|
||||||
return HostnameStatusResponse(
|
return HostnameStatusResponse(
|
||||||
hostname=str(account["hostname"]),
|
hostname=str(account["hostname"]),
|
||||||
status="active" if account.get("accepted_at") else "pending",
|
status=_account_status(account, include_disabled=False),
|
||||||
update_url=service.fritz_update_url(account_for_url),
|
update_url=service.fritz_update_url({"update_slug": str(account["update_slug"])}),
|
||||||
dashboard_url=service.dashboard_url(account_for_dashboard) if account_for_dashboard["management_slug"] else "",
|
dashboard_url=_dashboard_url(service, account),
|
||||||
ipv4=str(account["ipv4"]) if account.get("ipv4") else None,
|
ipv4=_row_text(account, "ipv4"),
|
||||||
ipv6=str(account["ipv6"]) if account.get("ipv6") else None,
|
ipv6=_row_text(account, "ipv6"),
|
||||||
updated_at=str(account["updated_at"]) if account.get("updated_at") else None,
|
updated_at=_row_text(account, "updated_at"),
|
||||||
history=[_hostname_update_event(event) for event in history or []],
|
history=[_hostname_update_event(event) for event in history or []],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -181,8 +179,8 @@ def _hostname_rotate_update_response(
|
|||||||
def _hostname_update_event(row: Mapping[str, object]) -> HostnameUpdateEvent:
|
def _hostname_update_event(row: Mapping[str, object]) -> HostnameUpdateEvent:
|
||||||
return HostnameUpdateEvent(
|
return HostnameUpdateEvent(
|
||||||
hostname=str(row["hostname"]),
|
hostname=str(row["hostname"]),
|
||||||
ipv4=str(row["ipv4"]) if row.get("ipv4") else None,
|
ipv4=_row_text(row, "ipv4"),
|
||||||
ipv6=str(row["ipv6"]) if row.get("ipv6") else None,
|
ipv6=_row_text(row, "ipv6"),
|
||||||
status=str(row["status"]),
|
status=str(row["status"]),
|
||||||
detail=str(row["detail"]),
|
detail=str(row["detail"]),
|
||||||
created_at=str(row["created_at"]),
|
created_at=str(row["created_at"]),
|
||||||
@@ -193,12 +191,12 @@ def _hostname_update_event(row: Mapping[str, object]) -> HostnameUpdateEvent:
|
|||||||
def _admin_hostname(account: Mapping[str, object]) -> AdminHostname:
|
def _admin_hostname(account: Mapping[str, object]) -> AdminHostname:
|
||||||
return AdminHostname(
|
return AdminHostname(
|
||||||
hostname=str(account["hostname"]),
|
hostname=str(account["hostname"]),
|
||||||
status="disabled" if account.get("disabled") else ("active" if account.get("accepted_at") else "pending"),
|
status=_account_status(account),
|
||||||
ipv4=str(account["ipv4"]) if account.get("ipv4") else None,
|
ipv4=_row_text(account, "ipv4"),
|
||||||
ipv6=str(account["ipv6"]) if account.get("ipv6") else None,
|
ipv6=_row_text(account, "ipv6"),
|
||||||
updated_at=str(account["updated_at"]) if account.get("updated_at") else None,
|
updated_at=_row_text(account, "updated_at"),
|
||||||
created_at=str(account["created_at"]) if account.get("created_at") else None,
|
created_at=_row_text(account, "created_at"),
|
||||||
accepted_at=str(account["accepted_at"]) if account.get("accepted_at") else None,
|
accepted_at=_row_text(account, "accepted_at"),
|
||||||
disabled=bool(account.get("disabled")),
|
disabled=bool(account.get("disabled")),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -212,8 +210,8 @@ def _admin_metrics(metrics: dict[str, float]) -> AdminMetrics:
|
|||||||
def _admin_record(row: Mapping[str, object]) -> AdminRecord:
|
def _admin_record(row: Mapping[str, object]) -> AdminRecord:
|
||||||
return AdminRecord(
|
return AdminRecord(
|
||||||
hostname=str(row["hostname"]),
|
hostname=str(row["hostname"]),
|
||||||
ipv4=str(row["ipv4"]) if row.get("ipv4") else None,
|
ipv4=_row_text(row, "ipv4"),
|
||||||
ipv6=str(row["ipv6"]) if row.get("ipv6") else None,
|
ipv6=_row_text(row, "ipv6"),
|
||||||
updated_at=str(row["updated_at"]),
|
updated_at=str(row["updated_at"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -221,8 +219,8 @@ def _admin_record(row: Mapping[str, object]) -> AdminRecord:
|
|||||||
def _admin_update_event(row: Mapping[str, object]) -> AdminUpdateEvent:
|
def _admin_update_event(row: Mapping[str, object]) -> AdminUpdateEvent:
|
||||||
return AdminUpdateEvent(
|
return AdminUpdateEvent(
|
||||||
hostname=str(row["hostname"]),
|
hostname=str(row["hostname"]),
|
||||||
ipv4=str(row["ipv4"]) if row.get("ipv4") else None,
|
ipv4=_row_text(row, "ipv4"),
|
||||||
ipv6=str(row["ipv6"]) if row.get("ipv6") else None,
|
ipv6=_row_text(row, "ipv6"),
|
||||||
status=str(row["status"]),
|
status=str(row["status"]),
|
||||||
detail=str(row["detail"]),
|
detail=str(row["detail"]),
|
||||||
source_ip=str(row["source_ip"]),
|
source_ip=str(row["source_ip"]),
|
||||||
@@ -243,16 +241,36 @@ def _admin_scheduled_task(row: Mapping[str, object]) -> AdminScheduledTask:
|
|||||||
name=str(row["name"]),
|
name=str(row["name"]),
|
||||||
interval_seconds=_int_value(row["interval_seconds"], 0),
|
interval_seconds=_int_value(row["interval_seconds"], 0),
|
||||||
next_run_at=str(row["next_run_at"]),
|
next_run_at=str(row["next_run_at"]),
|
||||||
lease_owner=str(row["lease_owner"]) if row.get("lease_owner") else None,
|
lease_owner=_row_text(row, "lease_owner"),
|
||||||
lease_expires_at=str(row["lease_expires_at"]) if row.get("lease_expires_at") else None,
|
lease_expires_at=_row_text(row, "lease_expires_at"),
|
||||||
last_started_at=str(row["last_started_at"]) if row.get("last_started_at") else None,
|
last_started_at=_row_text(row, "last_started_at"),
|
||||||
last_finished_at=str(row["last_finished_at"]) if row.get("last_finished_at") else None,
|
last_finished_at=_row_text(row, "last_finished_at"),
|
||||||
last_status=str(row["last_status"]),
|
last_status=str(row["last_status"]),
|
||||||
last_error=str(row["last_error"]),
|
last_error=str(row["last_error"]),
|
||||||
updated_at=str(row["updated_at"]),
|
updated_at=str(row["updated_at"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_text(row: Mapping[str, object], key: str) -> str | None:
|
||||||
|
value = row.get(key)
|
||||||
|
return str(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _account_status(account: Mapping[str, object], *, include_disabled: bool = True) -> str:
|
||||||
|
if include_disabled and account.get("disabled"):
|
||||||
|
return "disabled"
|
||||||
|
if account.get("accepted_at"):
|
||||||
|
return "active"
|
||||||
|
return "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def _dashboard_url(service: DdnsService, account: Mapping[str, object]) -> str:
|
||||||
|
management_slug = _row_text(account, "management_slug")
|
||||||
|
if management_slug is None:
|
||||||
|
return ""
|
||||||
|
return service.dashboard_url({"management_slug": management_slug})
|
||||||
|
|
||||||
|
|
||||||
def _int_value(value: object, default: int) -> int:
|
def _int_value(value: object, default: int) -> int:
|
||||||
if value is None:
|
if value is None:
|
||||||
return default
|
return default
|
||||||
|
|||||||
+98
-45
@@ -6,6 +6,7 @@ import hmac
|
|||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from ipaddress import ip_address, ip_network
|
from ipaddress import ip_address, ip_network
|
||||||
|
|
||||||
@@ -32,6 +33,23 @@ PRIVATE_PROXY_NETWORKS = tuple(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _DashboardUnlockCookieParts:
|
||||||
|
timestamp: int
|
||||||
|
max_age_seconds: int
|
||||||
|
signature: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DashboardUnlockIdentity:
|
||||||
|
hostname: str
|
||||||
|
management_slug: str
|
||||||
|
password_hash: str
|
||||||
|
|
||||||
|
def payload(self, timestamp: int, max_age_seconds: int) -> bytes:
|
||||||
|
return f"{self.hostname}\0{self.management_slug}\0{self.password_hash}\0{timestamp}\0{max_age_seconds}".encode()
|
||||||
|
|
||||||
|
|
||||||
def credentials_from_basic_auth(header: str) -> tuple[str | None, str | None]:
|
def credentials_from_basic_auth(header: str) -> tuple[str | None, str | None]:
|
||||||
try:
|
try:
|
||||||
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
|
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
|
||||||
@@ -69,9 +87,7 @@ def new_bearer_secret() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def sign_dashboard_unlock_cookie(
|
def sign_dashboard_unlock_cookie(
|
||||||
hostname: str,
|
identity: DashboardUnlockIdentity,
|
||||||
management_slug: str,
|
|
||||||
password_hash: str,
|
|
||||||
signing_secret: str,
|
signing_secret: str,
|
||||||
max_age_seconds: int,
|
max_age_seconds: int,
|
||||||
now: int | None = None,
|
now: int | None = None,
|
||||||
@@ -79,9 +95,7 @@ def sign_dashboard_unlock_cookie(
|
|||||||
timestamp = int(time.time() if now is None else now)
|
timestamp = int(time.time() if now is None else now)
|
||||||
bounded_max_age = _bounded_dashboard_unlock_seconds(max_age_seconds)
|
bounded_max_age = _bounded_dashboard_unlock_seconds(max_age_seconds)
|
||||||
signature = _dashboard_unlock_signature(
|
signature = _dashboard_unlock_signature(
|
||||||
hostname,
|
identity,
|
||||||
management_slug,
|
|
||||||
password_hash,
|
|
||||||
signing_secret,
|
signing_secret,
|
||||||
timestamp,
|
timestamp,
|
||||||
bounded_max_age,
|
bounded_max_age,
|
||||||
@@ -96,40 +110,63 @@ def verify_dashboard_unlock_cookie(
|
|||||||
signing_secret: str,
|
signing_secret: str,
|
||||||
cookie_value: str | None,
|
cookie_value: str | None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not password_hash or not signing_secret or not cookie_value:
|
identity = _dashboard_unlock_identity(hostname, management_slug, password_hash)
|
||||||
|
parts = _active_dashboard_unlock_cookie_parts(cookie_value)
|
||||||
|
if identity is None or not signing_secret or parts is None:
|
||||||
return False
|
return False
|
||||||
|
expected = _dashboard_unlock_signature(
|
||||||
|
identity,
|
||||||
|
signing_secret,
|
||||||
|
parts.timestamp,
|
||||||
|
parts.max_age_seconds,
|
||||||
|
)
|
||||||
|
return hmac.compare_digest(parts.signature, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def _dashboard_unlock_identity(
|
||||||
|
hostname: str,
|
||||||
|
management_slug: str,
|
||||||
|
password_hash: str | None,
|
||||||
|
) -> DashboardUnlockIdentity | None:
|
||||||
|
if not password_hash:
|
||||||
|
return None
|
||||||
|
return DashboardUnlockIdentity(hostname, management_slug, password_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def _active_dashboard_unlock_cookie_parts(cookie_value: str | None) -> _DashboardUnlockCookieParts | None:
|
||||||
|
if cookie_value is None:
|
||||||
|
return None
|
||||||
|
parts = _dashboard_unlock_cookie_parts(cookie_value)
|
||||||
|
if parts is None:
|
||||||
|
return None
|
||||||
|
if _dashboard_unlock_cookie_expired(parts, int(time.time())):
|
||||||
|
return None
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _dashboard_unlock_cookie_parts(cookie_value: str) -> _DashboardUnlockCookieParts | None:
|
||||||
try:
|
try:
|
||||||
version, raw_timestamp, raw_max_age, supplied = cookie_value.split(":", 3)
|
version, raw_timestamp, raw_max_age, signature = cookie_value.split(":", 3)
|
||||||
timestamp = int(raw_timestamp)
|
timestamp = int(raw_timestamp)
|
||||||
max_age_seconds = _bounded_dashboard_unlock_seconds(int(raw_max_age))
|
max_age_seconds = _bounded_dashboard_unlock_seconds(int(raw_max_age))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return None
|
||||||
if version != "v3":
|
if version != "v3":
|
||||||
return False
|
return None
|
||||||
now = int(time.time())
|
return _DashboardUnlockCookieParts(timestamp, max_age_seconds, signature)
|
||||||
if timestamp > now + 60 or now - timestamp > max_age_seconds:
|
|
||||||
return False
|
|
||||||
expected = _dashboard_unlock_signature(
|
def _dashboard_unlock_cookie_expired(parts: _DashboardUnlockCookieParts, now: int) -> bool:
|
||||||
hostname,
|
return parts.timestamp > now + 60 or now - parts.timestamp > parts.max_age_seconds
|
||||||
management_slug,
|
|
||||||
password_hash,
|
|
||||||
signing_secret,
|
|
||||||
timestamp,
|
|
||||||
max_age_seconds,
|
|
||||||
)
|
|
||||||
return hmac.compare_digest(supplied, expected)
|
|
||||||
|
|
||||||
|
|
||||||
def _dashboard_unlock_signature(
|
def _dashboard_unlock_signature(
|
||||||
hostname: str,
|
identity: DashboardUnlockIdentity,
|
||||||
management_slug: str,
|
|
||||||
password_hash: str,
|
|
||||||
signing_secret: str,
|
signing_secret: str,
|
||||||
timestamp: int,
|
timestamp: int,
|
||||||
max_age_seconds: int,
|
max_age_seconds: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
payload = f"{hostname}\0{management_slug}\0{password_hash}\0{timestamp}\0{max_age_seconds}".encode()
|
return hmac.new(signing_secret.encode(), identity.payload(timestamp, max_age_seconds), hashlib.sha256).hexdigest()
|
||||||
return hmac.new(signing_secret.encode(), payload, hashlib.sha256).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def dashboard_cookie_signing_secret(settings: DdnsSettings) -> str:
|
def dashboard_cookie_signing_secret(settings: DdnsSettings) -> str:
|
||||||
@@ -186,19 +223,20 @@ def rate_limit_bucket(path: str) -> str:
|
|||||||
def _trusted_proxy(peer: str, trusted: set[str]) -> bool:
|
def _trusted_proxy(peer: str, trusted: set[str]) -> bool:
|
||||||
if not trusted:
|
if not trusted:
|
||||||
return False
|
return False
|
||||||
|
return any(_trusted_proxy_candidate(peer, candidate) for candidate in trusted)
|
||||||
|
|
||||||
|
|
||||||
|
def _trusted_proxy_candidate(peer: str, candidate: str) -> bool:
|
||||||
try:
|
try:
|
||||||
peer_ip = ip_address(peer)
|
peer_ip = ip_address(peer)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
for candidate in trusted:
|
try:
|
||||||
try:
|
if "/" in candidate:
|
||||||
if "/" in candidate and peer_ip in ip_network(candidate, strict=False):
|
return peer_ip in ip_network(candidate, strict=False)
|
||||||
return True
|
return peer_ip == ip_address(candidate)
|
||||||
if "/" not in candidate and peer_ip == ip_address(candidate):
|
except ValueError:
|
||||||
return True
|
return False
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _should_trust_proxy_headers(peer: str, settings: DdnsSettings | None) -> bool:
|
def _should_trust_proxy_headers(peer: str, settings: DdnsSettings | None) -> bool:
|
||||||
@@ -250,13 +288,28 @@ def _valid_header_ip(value: str) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _strip_header_ip(value: str) -> str:
|
def _strip_header_ip(value: str) -> str:
|
||||||
cleaned = value.strip().strip('"').strip("'")
|
cleaned = _strip_header_ip_quotes(value)
|
||||||
if not cleaned or cleaned.lower() == "unknown":
|
if _empty_header_ip(cleaned):
|
||||||
return ""
|
return ""
|
||||||
if cleaned.startswith("[") and "]" in cleaned:
|
return _strip_bracketed_ip(cleaned) or _strip_host_port(cleaned) or cleaned
|
||||||
return cleaned[1 : cleaned.index("]")]
|
|
||||||
if cleaned.count(":") == 1:
|
|
||||||
host, port = cleaned.rsplit(":", 1)
|
def _strip_header_ip_quotes(value: str) -> str:
|
||||||
if port.isdigit():
|
return value.strip().strip('"').strip("'")
|
||||||
return host
|
|
||||||
return cleaned
|
|
||||||
|
def _empty_header_ip(value: str) -> bool:
|
||||||
|
return not value or value.lower() == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_bracketed_ip(value: str) -> str:
|
||||||
|
if value.startswith("[") and "]" in value:
|
||||||
|
return value[1 : value.index("]")]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_host_port(value: str) -> str:
|
||||||
|
if value.count(":") != 1:
|
||||||
|
return ""
|
||||||
|
host, port = value.rsplit(":", 1)
|
||||||
|
return host if port.isdigit() else ""
|
||||||
+112
-49
@@ -31,6 +31,35 @@ class UpdateOutcome:
|
|||||||
source_ip: str
|
source_ip: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class UpdateIpParams:
|
||||||
|
myip: str | None = None
|
||||||
|
ipaddr: str | None = None
|
||||||
|
myipv6: str | None = None
|
||||||
|
ip6addr: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def initial_plan_args(self) -> tuple[str | None, str | None, str | None, str | None]:
|
||||||
|
return self.myip, self.ipaddr, self.myipv6, self.ip6addr
|
||||||
|
|
||||||
|
def with_body_values(self, body_values: Mapping[str, str]) -> UpdateIpParams:
|
||||||
|
return UpdateIpParams(
|
||||||
|
myip=self.myip or body_values.get("myip"),
|
||||||
|
ipaddr=self.ipaddr or body_values.get("ipaddr"),
|
||||||
|
myipv6=self.myipv6 or body_values.get("myipv6"),
|
||||||
|
ip6addr=self.ip6addr or body_values.get("ip6addr"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class InitialDnsPlan:
|
||||||
|
ipv4: str | None
|
||||||
|
ipv6: str | None
|
||||||
|
update_ipv4: bool
|
||||||
|
update_ipv6: bool
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
class BadIpError(ValueError):
|
class BadIpError(ValueError):
|
||||||
"""Raised when a supplied DynDNS IP parameter is invalid."""
|
"""Raised when a supplied DynDNS IP parameter is invalid."""
|
||||||
|
|
||||||
@@ -71,36 +100,21 @@ class DdnsService:
|
|||||||
self,
|
self,
|
||||||
request: Request,
|
request: Request,
|
||||||
hostname: str,
|
hostname: str,
|
||||||
myip: str | None,
|
ip_params: UpdateIpParams,
|
||||||
ipaddr: str | None,
|
|
||||||
myipv6: str | None,
|
|
||||||
ip6addr: str | None,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not dns_backend_configured(self.settings):
|
if not dns_backend_configured(self.settings):
|
||||||
return False
|
return False
|
||||||
source_ip = client_ip(request, self.settings)
|
source_ip = client_ip(request, self.settings)
|
||||||
source_fallback = source_ip if _is_global_ip(source_ip) else ""
|
plan = _initial_dns_plan(source_ip, *ip_params.initial_plan_args)
|
||||||
supplied_ip = any(value and value.strip() for value in (myip, ipaddr, myipv6, ip6addr))
|
|
||||||
ipv4: str | None
|
|
||||||
ipv6: str | None
|
|
||||||
if not supplied_ip and not source_fallback:
|
|
||||||
ipv4 = INITIAL_PLACEHOLDER_IPV4
|
|
||||||
ipv6 = None
|
|
||||||
update_ipv4 = True
|
|
||||||
update_ipv6 = False
|
|
||||||
detail = "initial placeholder dns registered"
|
|
||||||
else:
|
|
||||||
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
|
|
||||||
source_fallback,
|
|
||||||
myip,
|
|
||||||
ipaddr,
|
|
||||||
myipv6,
|
|
||||||
ip6addr,
|
|
||||||
)
|
|
||||||
detail = "initial dns registered"
|
|
||||||
lock = self._lock_for(hostname)
|
lock = self._lock_for(hostname)
|
||||||
with lock:
|
with lock:
|
||||||
planned = self.store.preview_update(hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6)
|
planned = self.store.preview_update(
|
||||||
|
hostname,
|
||||||
|
plan.ipv4,
|
||||||
|
plan.ipv6,
|
||||||
|
update_ipv4=plan.update_ipv4,
|
||||||
|
update_ipv6=plan.update_ipv6,
|
||||||
|
)
|
||||||
if planned.changed:
|
if planned.changed:
|
||||||
try:
|
try:
|
||||||
publish_dns(planned, self.settings)
|
publish_dns(planned, self.settings)
|
||||||
@@ -113,7 +127,12 @@ class DdnsService:
|
|||||||
type(exc).__name__,
|
type(exc).__name__,
|
||||||
)
|
)
|
||||||
self.store.log_update_event(
|
self.store.log_update_event(
|
||||||
hostname, ipv4, ipv6, DNS_RECONCILE_FAILED, f"{type(exc).__name__}: {exc}", source_ip
|
hostname,
|
||||||
|
plan.ipv4,
|
||||||
|
plan.ipv6,
|
||||||
|
DNS_RECONCILE_FAILED,
|
||||||
|
f"{type(exc).__name__}: {exc}",
|
||||||
|
source_ip,
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
except DnsProviderError as exc:
|
except DnsProviderError as exc:
|
||||||
@@ -125,21 +144,23 @@ class DdnsService:
|
|||||||
type(exc).__name__,
|
type(exc).__name__,
|
||||||
)
|
)
|
||||||
self.store.log_update_event(
|
self.store.log_update_event(
|
||||||
hostname, ipv4, ipv6, DNS_FAILED, f"{type(exc).__name__}: {exc}", source_ip
|
hostname,
|
||||||
|
plan.ipv4,
|
||||||
|
plan.ipv6,
|
||||||
|
DNS_FAILED,
|
||||||
|
f"{type(exc).__name__}: {exc}",
|
||||||
|
source_ip,
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
result = self.store.commit_update_result(planned)
|
result = self.store.commit_update_result(planned)
|
||||||
self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, detail, source_ip)
|
self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, plan.detail, source_ip)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def apply_update(
|
def apply_update(
|
||||||
self,
|
self,
|
||||||
request: Request,
|
request: Request,
|
||||||
hostname: str,
|
hostname: str,
|
||||||
myip: str | None,
|
ip_params: UpdateIpParams,
|
||||||
ipaddr: str | None,
|
|
||||||
myipv6: str | None,
|
|
||||||
ip6addr: str | None,
|
|
||||||
) -> UpdateOutcome:
|
) -> UpdateOutcome:
|
||||||
started_at = time.perf_counter()
|
started_at = time.perf_counter()
|
||||||
is_success = False
|
is_success = False
|
||||||
@@ -150,10 +171,7 @@ class DdnsService:
|
|||||||
try:
|
try:
|
||||||
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
|
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
|
||||||
source_ip,
|
source_ip,
|
||||||
myip,
|
*ip_params.initial_plan_args,
|
||||||
ipaddr,
|
|
||||||
myipv6,
|
|
||||||
ip6addr,
|
|
||||||
)
|
)
|
||||||
lock = self._lock_for(hostname)
|
lock = self._lock_for(hostname)
|
||||||
with lock:
|
with lock:
|
||||||
@@ -248,13 +266,8 @@ class DdnsService:
|
|||||||
|
|
||||||
|
|
||||||
def normalize_ip(value: str | None, version: int | None = None) -> str | None:
|
def normalize_ip(value: str | None, version: int | None = None) -> str | None:
|
||||||
if not value or not value.strip():
|
ip = _optional_ip(value)
|
||||||
return None
|
if ip is None or (version and ip.version != version):
|
||||||
try:
|
|
||||||
ip = ipaddress.ip_address(value.strip())
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if version and ip.version != version:
|
|
||||||
return None
|
return None
|
||||||
return str(ip)
|
return str(ip)
|
||||||
|
|
||||||
@@ -280,6 +293,42 @@ def parse_update_ips(
|
|||||||
return ipv4, ipv6, update_ipv4, update_ipv6
|
return ipv4, ipv6, update_ipv4, update_ipv6
|
||||||
|
|
||||||
|
|
||||||
|
def _initial_dns_plan(
|
||||||
|
source_ip: str,
|
||||||
|
myip: str | None,
|
||||||
|
ipaddr: str | None,
|
||||||
|
myipv6: str | None,
|
||||||
|
ip6addr: str | None,
|
||||||
|
) -> InitialDnsPlan:
|
||||||
|
source_fallback = source_ip if _is_global_ip(source_ip) else ""
|
||||||
|
if not _has_supplied_ip(myip, ipaddr, myipv6, ip6addr) and not source_fallback:
|
||||||
|
return InitialDnsPlan(
|
||||||
|
ipv4=INITIAL_PLACEHOLDER_IPV4,
|
||||||
|
ipv6=None,
|
||||||
|
update_ipv4=True,
|
||||||
|
update_ipv6=False,
|
||||||
|
detail="initial placeholder dns registered",
|
||||||
|
)
|
||||||
|
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
|
||||||
|
source_fallback,
|
||||||
|
myip,
|
||||||
|
ipaddr,
|
||||||
|
myipv6,
|
||||||
|
ip6addr,
|
||||||
|
)
|
||||||
|
return InitialDnsPlan(
|
||||||
|
ipv4=ipv4,
|
||||||
|
ipv6=ipv6,
|
||||||
|
update_ipv4=update_ipv4,
|
||||||
|
update_ipv6=update_ipv6,
|
||||||
|
detail="initial dns registered",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_supplied_ip(*values: str | None) -> bool:
|
||||||
|
return any(value and value.strip() for value in values)
|
||||||
|
|
||||||
|
|
||||||
def _is_global_ip(value: str | None) -> bool:
|
def _is_global_ip(value: str | None) -> bool:
|
||||||
if not value:
|
if not value:
|
||||||
return False
|
return False
|
||||||
@@ -294,17 +343,31 @@ def _first_valid_ip(params: tuple[tuple[str, str | None], ...], version: int) ->
|
|||||||
for name, value in params:
|
for name, value in params:
|
||||||
if value is None or not value.strip():
|
if value is None or not value.strip():
|
||||||
continue
|
continue
|
||||||
try:
|
ip_value = _required_ip_param(name, value, version)
|
||||||
ip = ipaddress.ip_address(value.strip())
|
|
||||||
except ValueError as exc:
|
|
||||||
raise BadIpError(f"{name} must be a valid IPv{version} address") from exc
|
|
||||||
if ip.version != version:
|
|
||||||
raise BadIpError(f"{name} must be an IPv{version} address")
|
|
||||||
if selected is None:
|
if selected is None:
|
||||||
selected = str(ip)
|
selected = ip_value
|
||||||
return selected
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_ip(value: str | None) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
||||||
|
if not value or not value.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return ipaddress.ip_address(value.strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _required_ip_param(name: str, value: str, version: int) -> str:
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(value.strip())
|
||||||
|
except ValueError as exc:
|
||||||
|
raise BadIpError(f"{name} must be a valid IPv{version} address") from exc
|
||||||
|
if ip.version != version:
|
||||||
|
raise BadIpError(f"{name} must be an IPv{version} address")
|
||||||
|
return str(ip)
|
||||||
|
|
||||||
|
|
||||||
def normalize_hostname(value: str, settings: DdnsSettings, expand_suffix: bool = True) -> str:
|
def normalize_hostname(value: str, settings: DdnsSettings, expand_suffix: bool = True) -> str:
|
||||||
hostname = value.strip().lower().rstrip(".")
|
hostname = value.strip().lower().rstrip(".")
|
||||||
suffix = settings.hostname_suffix.strip().lower().strip(".")
|
suffix = settings.hostname_suffix.strip().lower().strip(".")
|
||||||
|
|||||||
+70
-44
@@ -6,6 +6,34 @@ from pathlib import Path
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
_RESERVED_PUBLIC_PATHS = {
|
||||||
|
"/admin.css",
|
||||||
|
"/admin-tables.js",
|
||||||
|
"/api",
|
||||||
|
"/app.js",
|
||||||
|
"/clipboard.js",
|
||||||
|
"/docs",
|
||||||
|
"/favicon.ico",
|
||||||
|
"/healthz",
|
||||||
|
"/install.sh",
|
||||||
|
"/bootstrap-subset.css",
|
||||||
|
"/logo.png",
|
||||||
|
"/logo-nav.webp",
|
||||||
|
"/logo.webp",
|
||||||
|
"/m",
|
||||||
|
"/openapi.json",
|
||||||
|
"/privacy",
|
||||||
|
"/redoc",
|
||||||
|
"/robots.txt",
|
||||||
|
"/sitemap.xml",
|
||||||
|
"/site.webmanifest",
|
||||||
|
"/social-preview.png",
|
||||||
|
"/social-preview.webp",
|
||||||
|
"/theme.js",
|
||||||
|
"/uninstall.sh",
|
||||||
|
"/visitor-ip.js",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class DdnsSettings(BaseModel):
|
class DdnsSettings(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
@@ -42,52 +70,25 @@ class DdnsSettings(BaseModel):
|
|||||||
|
|
||||||
@field_validator("public_base_url")
|
@field_validator("public_base_url")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_public_base_url(cls, value: str) -> str:
|
def validate_public_base_url(_cls, value: str) -> str:
|
||||||
if not value.startswith(("http://", "https://")):
|
if not value.startswith(("http://", "https://")):
|
||||||
raise ValueError("public_base_url must start with http:// or https://")
|
raise ValueError("public_base_url must start with http:// or https://")
|
||||||
return value.rstrip("/")
|
return value.rstrip("/")
|
||||||
|
|
||||||
@field_validator("trusted_hosts")
|
@field_validator("trusted_hosts")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_trusted_hosts(cls, value: set[str]) -> set[str]:
|
def validate_trusted_hosts(_cls, value: set[str]) -> set[str]:
|
||||||
return {host.strip().lower() for host in value if host.strip()}
|
return {host.strip().lower() for host in value if host.strip()}
|
||||||
|
|
||||||
@field_validator("admin_path")
|
@field_validator("admin_path")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_admin_path(cls, value: str) -> str:
|
def validate_admin_path(_cls, value: str) -> str:
|
||||||
path = value.strip()
|
path = value.strip()
|
||||||
if not path.startswith("/") or path == "/" or path.endswith("/"):
|
if not _has_valid_admin_path_shape(path):
|
||||||
raise ValueError("admin_path must start with / and must not be / or end with /")
|
raise ValueError("admin_path must start with / and must not be / or end with /")
|
||||||
if not re.fullmatch(r"/[A-Za-z0-9._~/-]+", path):
|
if not _has_url_safe_path_chars(path):
|
||||||
raise ValueError("admin_path may only contain URL-safe path characters")
|
raise ValueError("admin_path may only contain URL-safe path characters")
|
||||||
reserved_paths = {
|
if _is_reserved_admin_path(path):
|
||||||
"/admin.css",
|
|
||||||
"/admin-tables.js",
|
|
||||||
"/api",
|
|
||||||
"/app.js",
|
|
||||||
"/clipboard.js",
|
|
||||||
"/docs",
|
|
||||||
"/favicon.ico",
|
|
||||||
"/healthz",
|
|
||||||
"/install.sh",
|
|
||||||
"/bootstrap-subset.css",
|
|
||||||
"/logo.png",
|
|
||||||
"/logo-nav.webp",
|
|
||||||
"/logo.webp",
|
|
||||||
"/m",
|
|
||||||
"/openapi.json",
|
|
||||||
"/privacy",
|
|
||||||
"/redoc",
|
|
||||||
"/robots.txt",
|
|
||||||
"/sitemap.xml",
|
|
||||||
"/site.webmanifest",
|
|
||||||
"/social-preview.png",
|
|
||||||
"/social-preview.webp",
|
|
||||||
"/theme.js",
|
|
||||||
"/uninstall.sh",
|
|
||||||
"/visitor-ip.js",
|
|
||||||
}
|
|
||||||
if any(path == reserved or path.startswith(f"{reserved}/") for reserved in reserved_paths):
|
|
||||||
raise ValueError("admin_path must not conflict with a public route")
|
raise ValueError("admin_path must not conflict with a public route")
|
||||||
return path
|
return path
|
||||||
|
|
||||||
@@ -107,19 +108,44 @@ class DdnsSettings(BaseModel):
|
|||||||
validate_dns_provider_settings(self)
|
validate_dns_provider_settings(self)
|
||||||
if not self.require_dns_provider:
|
if not self.require_dns_provider:
|
||||||
return
|
return
|
||||||
if len(self.admin_password) < 16:
|
_validate_launch_secrets(self.admin_password, self.dashboard_cookie_secret)
|
||||||
raise RuntimeError("DDNS_ADMIN_PASSWORD must be at least 16 characters when DDNS_REQUIRE_DNS_PROVIDER=1")
|
_validate_launch_hostname_suffix(self.hostname_suffix)
|
||||||
if len(self.dashboard_cookie_secret or self.admin_password) < 32:
|
_validate_launch_trusted_hosts(self.effective_trusted_hosts)
|
||||||
raise RuntimeError(
|
|
||||||
"DDNS_DASHBOARD_COOKIE_SECRET or DDNS_ADMIN_PASSWORD must be at least 32 characters for dashboard cookie signing when DDNS_REQUIRE_DNS_PROVIDER=1"
|
|
||||||
)
|
|
||||||
if not self.hostname_suffix:
|
|
||||||
raise RuntimeError("DDNS_HOSTNAME_SUFFIX is required when DDNS_REQUIRE_DNS_PROVIDER=1")
|
|
||||||
if not self.effective_trusted_hosts:
|
|
||||||
raise RuntimeError("At least one trusted host is required for production")
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> DdnsSettings:
|
def from_env(_cls) -> DdnsSettings:
|
||||||
from .config import settings_from_env
|
from .config import settings_from_env
|
||||||
|
|
||||||
return settings_from_env()
|
return settings_from_env()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_valid_admin_path_shape(path: str) -> bool:
|
||||||
|
return path.startswith("/") and path != "/" and not path.endswith("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _has_url_safe_path_chars(path: str) -> bool:
|
||||||
|
return re.fullmatch(r"/[A-Za-z0-9._~/-]+", path) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_reserved_admin_path(path: str) -> bool:
|
||||||
|
return any(path == reserved or path.startswith(f"{reserved}/") for reserved in _RESERVED_PUBLIC_PATHS)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_launch_secrets(admin_password: str, dashboard_cookie_secret: str) -> None:
|
||||||
|
if len(admin_password) < 16:
|
||||||
|
raise RuntimeError("DDNS_ADMIN_PASSWORD must be at least 16 characters when DDNS_REQUIRE_DNS_PROVIDER=1")
|
||||||
|
if len(dashboard_cookie_secret or admin_password) < 32:
|
||||||
|
raise RuntimeError(
|
||||||
|
"DDNS_DASHBOARD_COOKIE_SECRET or DDNS_ADMIN_PASSWORD must be at least 32 characters "
|
||||||
|
"for dashboard cookie signing when DDNS_REQUIRE_DNS_PROVIDER=1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_launch_hostname_suffix(hostname_suffix: str) -> None:
|
||||||
|
if not hostname_suffix:
|
||||||
|
raise RuntimeError("DDNS_HOSTNAME_SUFFIX is required when DDNS_REQUIRE_DNS_PROVIDER=1")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_launch_trusted_hosts(effective_trusted_hosts: list[str]) -> None:
|
||||||
|
if not effective_trusted_hosts:
|
||||||
|
raise RuntimeError("At least one trusted host is required for production")
|
||||||
@@ -9,6 +9,7 @@ from .types import AccountRow, account_row
|
|||||||
|
|
||||||
class AccountsMixin(StoreConnectionMixin):
|
class AccountsMixin(StoreConnectionMixin):
|
||||||
def create_account(self, hostname: str, username: str | None = None, *, accepted: bool = True) -> AccountRow:
|
def create_account(self, hostname: str, username: str | None = None, *, accepted: bool = True) -> AccountRow:
|
||||||
|
_ = username
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
update_slug = new_bearer_secret()
|
update_slug = new_bearer_secret()
|
||||||
management_slug = new_bearer_secret()
|
management_slug = new_bearer_secret()
|
||||||
@@ -27,6 +28,7 @@ class AccountsMixin(StoreConnectionMixin):
|
|||||||
"update_slug": update_slug,
|
"update_slug": update_slug,
|
||||||
"management_slug": management_slug,
|
"management_slug": management_slug,
|
||||||
"accepted_at": accepted_at,
|
"accepted_at": accepted_at,
|
||||||
|
"disabled": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
def list_accounts(self, limit: int = 100, offset: int = 0) -> list[AccountRow]:
|
def list_accounts(self, limit: int = 100, offset: int = 0) -> list[AccountRow]:
|
||||||
@@ -34,7 +36,8 @@ class AccountsMixin(StoreConnectionMixin):
|
|||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT accounts.hostname, accounts.update_slug,
|
SELECT accounts.hostname, accounts.update_slug,
|
||||||
accounts.created_at, accounts.accepted_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
|
accounts.created_at, accounts.accepted_at, accounts.disabled,
|
||||||
|
records.ipv4, records.ipv6, records.updated_at
|
||||||
FROM accounts
|
FROM accounts
|
||||||
LEFT JOIN records ON records.hostname = accounts.hostname
|
LEFT JOIN records ON records.hostname = accounts.hostname
|
||||||
ORDER BY accounts.hostname
|
ORDER BY accounts.hostname
|
||||||
@@ -54,7 +57,8 @@ class AccountsMixin(StoreConnectionMixin):
|
|||||||
account = conn.execute(
|
account = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT accounts.hostname, accounts.update_slug, accounts.dashboard_password_hash,
|
SELECT accounts.hostname, accounts.update_slug, accounts.dashboard_password_hash,
|
||||||
accounts.created_at, accounts.accepted_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
|
accounts.created_at, accounts.accepted_at, accounts.disabled,
|
||||||
|
records.ipv4, records.ipv6, records.updated_at
|
||||||
FROM accounts
|
FROM accounts
|
||||||
LEFT JOIN records ON records.hostname = accounts.hostname
|
LEFT JOIN records ON records.hostname = accounts.hostname
|
||||||
WHERE accounts.hostname = ?
|
WHERE accounts.hostname = ?
|
||||||
@@ -79,7 +83,8 @@ class AccountsMixin(StoreConnectionMixin):
|
|||||||
account = conn.execute(
|
account = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT accounts.hostname, accounts.update_slug, accounts.dashboard_password_hash,
|
SELECT accounts.hostname, accounts.update_slug, accounts.dashboard_password_hash,
|
||||||
accounts.created_at, accounts.accepted_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
|
accounts.created_at, accounts.accepted_at, accounts.disabled,
|
||||||
|
records.ipv4, records.ipv6, records.updated_at
|
||||||
FROM accounts
|
FROM accounts
|
||||||
LEFT JOIN records ON records.hostname = accounts.hostname
|
LEFT JOIN records ON records.hostname = accounts.hostname
|
||||||
WHERE accounts.management_hash = ?
|
WHERE accounts.management_hash = ?
|
||||||
|
|||||||
+30
-13
@@ -5,6 +5,29 @@ from datetime import UTC, datetime
|
|||||||
from .base import StoreConnectionMixin
|
from .base import StoreConnectionMixin
|
||||||
|
|
||||||
|
|
||||||
|
def _update_metric_increments(
|
||||||
|
latency_ms: float,
|
||||||
|
*,
|
||||||
|
is_success: bool,
|
||||||
|
is_dns_failure: bool,
|
||||||
|
is_reconcile_failure: bool,
|
||||||
|
is_bad_ip: bool,
|
||||||
|
) -> dict[str, float]:
|
||||||
|
increments: dict[str, float] = {
|
||||||
|
"update_requests_total": 1,
|
||||||
|
"update_latency_ms_total": latency_ms,
|
||||||
|
"update_latency_samples_total": 1,
|
||||||
|
}
|
||||||
|
optional_increments = {
|
||||||
|
"update_success_total": is_success,
|
||||||
|
"dns_failures_total": is_dns_failure,
|
||||||
|
"dns_reconcile_failures_total": is_reconcile_failure,
|
||||||
|
"bad_ip_total": is_bad_ip,
|
||||||
|
}
|
||||||
|
increments.update({name: 1 for name, enabled in optional_increments.items() if enabled})
|
||||||
|
return increments
|
||||||
|
|
||||||
|
|
||||||
class MetricsMixin(StoreConnectionMixin):
|
class MetricsMixin(StoreConnectionMixin):
|
||||||
def increment_metric(self, name: str, amount: float = 1.0) -> None:
|
def increment_metric(self, name: str, amount: float = 1.0) -> None:
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
@@ -48,19 +71,13 @@ class MetricsMixin(StoreConnectionMixin):
|
|||||||
is_reconcile_failure: bool = False,
|
is_reconcile_failure: bool = False,
|
||||||
is_bad_ip: bool = False,
|
is_bad_ip: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
increments = {
|
increments = _update_metric_increments(
|
||||||
"update_requests_total": 1,
|
latency_ms,
|
||||||
"update_latency_ms_total": latency_ms,
|
is_success=is_success,
|
||||||
"update_latency_samples_total": 1,
|
is_dns_failure=is_dns_failure,
|
||||||
}
|
is_reconcile_failure=is_reconcile_failure,
|
||||||
if is_success:
|
is_bad_ip=is_bad_ip,
|
||||||
increments["update_success_total"] = 1
|
)
|
||||||
if is_dns_failure:
|
|
||||||
increments["dns_failures_total"] = 1
|
|
||||||
if is_reconcile_failure:
|
|
||||||
increments["dns_reconcile_failures_total"] = 1
|
|
||||||
if is_bad_ip:
|
|
||||||
increments["bad_ip_total"] = 1
|
|
||||||
|
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
|
|||||||
@@ -199,9 +199,11 @@ class RateLimitCleanupMixin(StoreConnectionMixin):
|
|||||||
[
|
[
|
||||||
(
|
(
|
||||||
row["hostname"],
|
row["hostname"],
|
||||||
CLEANUP_PENDING_HOSTNAME_DELETED
|
(
|
||||||
if row["cleanup_reason"] == "pending"
|
CLEANUP_PENDING_HOSTNAME_DELETED
|
||||||
else CLEANUP_UNUSED_HOSTNAME_DELETED,
|
if row["cleanup_reason"] == "pending"
|
||||||
|
else CLEANUP_UNUSED_HOSTNAME_DELETED
|
||||||
|
),
|
||||||
"cleanup deleted abandoned hostname",
|
"cleanup deleted abandoned hostname",
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
datetime.now(UTC).isoformat(),
|
datetime.now(UTC).isoformat(),
|
||||||
|
|||||||
+93
-42
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from ..models import UpdateResult
|
from ..models import UpdateResult
|
||||||
@@ -8,6 +9,60 @@ from .base import StoreConnectionMixin
|
|||||||
from .types import RecordRow, UpdateEventRow, record_row, update_event_row
|
from .types import RecordRow, UpdateEventRow, record_row, update_event_row
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class UpdateEventInput:
|
||||||
|
hostname: str
|
||||||
|
ipv4: str | None
|
||||||
|
ipv6: str | None
|
||||||
|
status: str
|
||||||
|
detail: str
|
||||||
|
source_ip: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RecordUpdateInput:
|
||||||
|
hostname: str
|
||||||
|
ipv4: str | None
|
||||||
|
ipv6: str | None
|
||||||
|
update_ipv4: bool = True
|
||||||
|
update_ipv6: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def _update_event_input(event: UpdateEventInput | str, legacy_values: tuple[object, ...]) -> UpdateEventInput:
|
||||||
|
if isinstance(event, UpdateEventInput):
|
||||||
|
return event
|
||||||
|
if len(legacy_values) != 5:
|
||||||
|
raise TypeError("legacy update event values must be ipv4, ipv6, status, detail, source_ip")
|
||||||
|
ipv4, ipv6, status, detail, source_ip = legacy_values
|
||||||
|
return UpdateEventInput(
|
||||||
|
hostname=event,
|
||||||
|
ipv4=str(ipv4) if ipv4 is not None else None,
|
||||||
|
ipv6=str(ipv6) if ipv6 is not None else None,
|
||||||
|
status=str(status),
|
||||||
|
detail=str(detail),
|
||||||
|
source_ip=str(source_ip),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _planned_record_update(
|
||||||
|
update: RecordUpdateInput,
|
||||||
|
*,
|
||||||
|
current_ipv4: str | None,
|
||||||
|
current_ipv6: str | None,
|
||||||
|
existing: bool,
|
||||||
|
) -> UpdateResult:
|
||||||
|
effective_ipv4 = update.ipv4 if update.update_ipv4 else current_ipv4
|
||||||
|
effective_ipv6 = update.ipv6 if update.update_ipv6 else current_ipv6
|
||||||
|
return UpdateResult(
|
||||||
|
hostname=update.hostname,
|
||||||
|
ipv4=effective_ipv4,
|
||||||
|
ipv6=effective_ipv6,
|
||||||
|
changed=not existing or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6,
|
||||||
|
update_ipv4=update.update_ipv4,
|
||||||
|
update_ipv6=update.update_ipv6,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RecordsEventsMixin(StoreConnectionMixin):
|
class RecordsEventsMixin(StoreConnectionMixin):
|
||||||
def upsert(
|
def upsert(
|
||||||
self,
|
self,
|
||||||
@@ -18,13 +73,17 @@ class RecordsEventsMixin(StoreConnectionMixin):
|
|||||||
update_ipv6: bool = True,
|
update_ipv6: bool = True,
|
||||||
) -> UpdateResult:
|
) -> UpdateResult:
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
|
update = RecordUpdateInput(hostname, ipv4, ipv6, update_ipv4, update_ipv6)
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
|
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
|
||||||
current_ipv4 = existing["ipv4"] if existing else None
|
current_ipv4 = existing["ipv4"] if existing else None
|
||||||
current_ipv6 = existing["ipv6"] if existing else None
|
current_ipv6 = existing["ipv6"] if existing else None
|
||||||
effective_ipv4 = ipv4 if update_ipv4 else current_ipv4
|
result = _planned_record_update(
|
||||||
effective_ipv6 = ipv6 if update_ipv6 else current_ipv6
|
update,
|
||||||
changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6
|
current_ipv4=current_ipv4,
|
||||||
|
current_ipv6=current_ipv6,
|
||||||
|
existing=existing is not None,
|
||||||
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO records(hostname, ipv4, ipv6, updated_at)
|
INSERT INTO records(hostname, ipv4, ipv6, updated_at)
|
||||||
@@ -34,16 +93,9 @@ class RecordsEventsMixin(StoreConnectionMixin):
|
|||||||
ipv6=excluded.ipv6,
|
ipv6=excluded.ipv6,
|
||||||
updated_at=excluded.updated_at
|
updated_at=excluded.updated_at
|
||||||
""",
|
""",
|
||||||
(hostname, effective_ipv4, effective_ipv6, now),
|
(result.hostname, result.ipv4, result.ipv6, now),
|
||||||
)
|
)
|
||||||
return UpdateResult(
|
return result
|
||||||
hostname=hostname,
|
|
||||||
ipv4=effective_ipv4,
|
|
||||||
ipv6=effective_ipv6,
|
|
||||||
changed=changed,
|
|
||||||
update_ipv4=update_ipv4,
|
|
||||||
update_ipv6=update_ipv6,
|
|
||||||
)
|
|
||||||
|
|
||||||
def commit_update_result(self, result: UpdateResult) -> UpdateResult:
|
def commit_update_result(self, result: UpdateResult) -> UpdateResult:
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
@@ -69,20 +121,16 @@ class RecordsEventsMixin(StoreConnectionMixin):
|
|||||||
update_ipv4: bool = True,
|
update_ipv4: bool = True,
|
||||||
update_ipv6: bool = True,
|
update_ipv6: bool = True,
|
||||||
) -> UpdateResult:
|
) -> UpdateResult:
|
||||||
|
update = RecordUpdateInput(hostname, ipv4, ipv6, update_ipv4, update_ipv6)
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
|
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
|
||||||
current_ipv4 = existing["ipv4"] if existing else None
|
current_ipv4 = existing["ipv4"] if existing else None
|
||||||
current_ipv6 = existing["ipv6"] if existing else None
|
current_ipv6 = existing["ipv6"] if existing else None
|
||||||
effective_ipv4 = ipv4 if update_ipv4 else current_ipv4
|
return _planned_record_update(
|
||||||
effective_ipv6 = ipv6 if update_ipv6 else current_ipv6
|
update,
|
||||||
changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6
|
current_ipv4=current_ipv4,
|
||||||
return UpdateResult(
|
current_ipv6=current_ipv6,
|
||||||
hostname=hostname,
|
existing=existing is not None,
|
||||||
ipv4=effective_ipv4,
|
|
||||||
ipv6=effective_ipv6,
|
|
||||||
changed=changed,
|
|
||||||
update_ipv4=update_ipv4,
|
|
||||||
update_ipv6=update_ipv6,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def list_records(self) -> list[RecordRow]:
|
def list_records(self) -> list[RecordRow]:
|
||||||
@@ -106,33 +154,27 @@ class RecordsEventsMixin(StoreConnectionMixin):
|
|||||||
row = conn.execute("SELECT COUNT(*) FROM records").fetchone()
|
row = conn.execute("SELECT COUNT(*) FROM records").fetchone()
|
||||||
return int(row[0])
|
return int(row[0])
|
||||||
|
|
||||||
def log_update_event(
|
def log_update_event(self, event: UpdateEventInput | str, *legacy_values: object) -> None:
|
||||||
self,
|
event = _update_event_input(event, legacy_values)
|
||||||
hostname: str,
|
|
||||||
ipv4: str | None,
|
|
||||||
ipv6: str | None,
|
|
||||||
status: str,
|
|
||||||
detail: str,
|
|
||||||
source_ip: str,
|
|
||||||
) -> None:
|
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO update_events(hostname, ipv4, ipv6, status, detail, source_ip, created_at)
|
INSERT INTO update_events(hostname, ipv4, ipv6, status, detail, source_ip, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(hostname, ipv4, ipv6, status, detail[:500], source_ip, datetime.now(UTC).isoformat()),
|
(
|
||||||
|
event.hostname,
|
||||||
|
event.ipv4,
|
||||||
|
event.ipv6,
|
||||||
|
event.status,
|
||||||
|
event.detail[:500],
|
||||||
|
event.source_ip,
|
||||||
|
datetime.now(UTC).isoformat(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def log_update_rollup(
|
def log_update_rollup(self, event: UpdateEventInput | str, *legacy_values: object) -> None:
|
||||||
self,
|
event = _update_event_input(event, legacy_values)
|
||||||
hostname: str,
|
|
||||||
ipv4: str | None,
|
|
||||||
ipv6: str | None,
|
|
||||||
status: str,
|
|
||||||
detail: str,
|
|
||||||
source_ip: str,
|
|
||||||
) -> None:
|
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -145,7 +187,16 @@ class RecordsEventsMixin(StoreConnectionMixin):
|
|||||||
last_seen_at=excluded.last_seen_at,
|
last_seen_at=excluded.last_seen_at,
|
||||||
count=count + 1
|
count=count + 1
|
||||||
""",
|
""",
|
||||||
(hostname, ipv4 or "", ipv6 or "", status, detail[:500], source_ip, now, now),
|
(
|
||||||
|
event.hostname,
|
||||||
|
event.ipv4 or "",
|
||||||
|
event.ipv6 or "",
|
||||||
|
event.status,
|
||||||
|
event.detail[:500],
|
||||||
|
event.source_ip,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def list_update_events(self, limit: int = 100, offset: int = 0) -> list[UpdateEventRow]:
|
def list_update_events(self, limit: int = 100, offset: int = 0) -> list[UpdateEventRow]:
|
||||||
|
|||||||
@@ -186,7 +186,10 @@ def _migrate_accounts_schema(conn: sqlite3.Connection) -> None:
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT INTO accounts_new(hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled)
|
INSERT INTO accounts_new(
|
||||||
|
hostname, update_slug, management_hash, dashboard_password_hash,
|
||||||
|
created_at, accepted_at, disabled
|
||||||
|
)
|
||||||
SELECT hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled
|
SELECT hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled
|
||||||
FROM accounts
|
FROM accounts
|
||||||
""")
|
""")
|
||||||
|
|||||||
+12
-12
@@ -1,22 +1,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, TypedDict, cast
|
from typing import Any, NotRequired, TypedDict, cast
|
||||||
|
|
||||||
|
|
||||||
class AccountRow(TypedDict, total=False):
|
class AccountRow(TypedDict):
|
||||||
hostname: str
|
hostname: str
|
||||||
update_slug: str
|
update_slug: str
|
||||||
management_slug: str
|
|
||||||
dashboard_password_hash: str | None
|
|
||||||
created_at: str
|
|
||||||
accepted_at: str | None
|
accepted_at: str | None
|
||||||
disabled: int
|
disabled: int
|
||||||
ipv4: str | None
|
management_slug: NotRequired[str]
|
||||||
ipv6: str | None
|
dashboard_password_hash: NotRequired[str | None]
|
||||||
updated_at: str | None
|
created_at: NotRequired[str]
|
||||||
|
ipv4: NotRequired[str | None]
|
||||||
|
ipv6: NotRequired[str | None]
|
||||||
|
updated_at: NotRequired[str | None]
|
||||||
|
|
||||||
|
|
||||||
class UpdateEventRow(TypedDict, total=False):
|
class UpdateEventRow(TypedDict):
|
||||||
hostname: str
|
hostname: str
|
||||||
ipv4: str | None
|
ipv4: str | None
|
||||||
ipv6: str | None
|
ipv6: str | None
|
||||||
@@ -33,9 +33,9 @@ class CleanupRunRow(TypedDict):
|
|||||||
|
|
||||||
|
|
||||||
class ScheduledTaskRow(TypedDict, total=False):
|
class ScheduledTaskRow(TypedDict, total=False):
|
||||||
name: str
|
name: NotRequired[str]
|
||||||
interval_seconds: int
|
interval_seconds: NotRequired[int]
|
||||||
next_run_at: str
|
next_run_at: NotRequired[str]
|
||||||
lease_owner: str | None
|
lease_owner: str | None
|
||||||
lease_expires_at: str | None
|
lease_expires_at: str | None
|
||||||
last_started_at: str | None
|
last_started_at: str | None
|
||||||
|
|||||||
+110
-39
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
|
|
||||||
from .html_utils import SafeHtml, attr, escape, trusted
|
from .html_utils import SafeHtml, attr, escape, trusted
|
||||||
@@ -28,32 +29,77 @@ def _event_label(status: object) -> str:
|
|||||||
return _EVENT_LABELS.get(value, value.replace("_", " "))
|
return _EVENT_LABELS.get(value, value.replace("_", " "))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _CopyRowParts:
|
||||||
|
label: str
|
||||||
|
value: str
|
||||||
|
safe_label: str
|
||||||
|
safe_value: str
|
||||||
|
attr_label: str
|
||||||
|
attr_value: str
|
||||||
|
is_secret: bool
|
||||||
|
should_mask: bool
|
||||||
|
sensitivity_badge: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def row_class(self) -> str:
|
||||||
|
return "copy-field copy-field-secret" if self.is_secret else "copy-field copy-field-public"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value_field(self) -> str:
|
||||||
|
if self.should_mask:
|
||||||
|
return (
|
||||||
|
f'<input class="copy-pill-value copy-value" type="password" value="{self.attr_value}" '
|
||||||
|
"readonly data-secret-input>"
|
||||||
|
)
|
||||||
|
return f'<code class="copy-pill-value copy-value">{self.safe_value}</code>'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reveal_button(self) -> str:
|
||||||
|
if not self.should_mask:
|
||||||
|
return ""
|
||||||
|
return (
|
||||||
|
f'<button type="button" class="btn btn-outline-secondary rounded-pill copy-reveal-button" '
|
||||||
|
f'data-secret-toggle aria-label="Show {self.attr_label}">View</button>'
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_values(
|
||||||
|
cls, label: str, value: str, sensitivity: str | None = None, *, mask_secret: bool = True
|
||||||
|
) -> _CopyRowParts:
|
||||||
|
is_secret = bool(sensitivity and sensitivity.lower() == "secret")
|
||||||
|
return cls(
|
||||||
|
label=label,
|
||||||
|
value=value,
|
||||||
|
safe_label=escape(label),
|
||||||
|
safe_value=escape(value),
|
||||||
|
attr_label=attr(label),
|
||||||
|
attr_value=attr(value),
|
||||||
|
is_secret=is_secret,
|
||||||
|
should_mask=is_secret and mask_secret,
|
||||||
|
sensitivity_badge=_copy_row_sensitivity_badge(sensitivity, is_secret),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_row_sensitivity_badge(sensitivity: str | None, is_secret: bool) -> str:
|
||||||
|
if not sensitivity:
|
||||||
|
return ""
|
||||||
|
badge_kind = "secret" if is_secret else "public"
|
||||||
|
return f'<span class="ui-badge ui-badge-{badge_kind}">{escape(sensitivity)}</span>'
|
||||||
|
|
||||||
|
|
||||||
def _copy_row(label: str, value: str, sensitivity: str | None = None, *, mask_secret: bool = True) -> SafeHtml:
|
def _copy_row(label: str, value: str, sensitivity: str | None = None, *, mask_secret: bool = True) -> SafeHtml:
|
||||||
safe_label = escape(label)
|
parts = _CopyRowParts.from_values(label, value, sensitivity, mask_secret=mask_secret)
|
||||||
safe_value = escape(value)
|
|
||||||
attr_label = attr(label)
|
|
||||||
attr_value = attr(value)
|
|
||||||
is_secret = bool(sensitivity and sensitivity.lower() == "secret")
|
|
||||||
should_mask = is_secret and mask_secret
|
|
||||||
value_field = (
|
|
||||||
f'<input class="copy-pill-value copy-value" type="password" value="{attr_value}" readonly data-secret-input>'
|
|
||||||
if should_mask
|
|
||||||
else f'<code class="copy-pill-value copy-value">{safe_value}</code>'
|
|
||||||
)
|
|
||||||
reveal_button = (
|
|
||||||
f'<button type="button" class="btn btn-outline-secondary rounded-pill copy-reveal-button" data-secret-toggle aria-label="Show {attr_label}">View</button>'
|
|
||||||
if should_mask
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
row_class = "copy-field copy-field-secret" if is_secret else "copy-field copy-field-public"
|
|
||||||
return trusted(f"""
|
return trusted(f"""
|
||||||
<div class="{row_class}">
|
<div class="{parts.row_class}">
|
||||||
<div class="copy-pill">
|
<div class="copy-pill">
|
||||||
<span class="copy-pill-label">{safe_label}</span>
|
<span class="copy-pill-label">{parts.safe_label}</span>
|
||||||
{value_field}
|
{parts.sensitivity_badge}
|
||||||
{reveal_button}
|
{parts.value_field}
|
||||||
|
{parts.reveal_button}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-outline-secondary rounded-pill copy-button" data-copy="{attr_value}" aria-label="Copy {attr_label}">
|
<button type="button" class="btn btn-outline-secondary rounded-pill copy-button"
|
||||||
|
data-copy="{parts.attr_value}" aria-label="Copy {parts.attr_label}">
|
||||||
{_svg_icon("clipboard")}<span data-copy-label>Copy</span>{_svg_icon("check", "check-icon")}
|
{_svg_icon("clipboard")}<span data-copy-label>Copy</span>{_svg_icon("check", "check-icon")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,16 +112,22 @@ def _copy_button(label: str, value: str, *, style: str = "outline-primary") -> S
|
|||||||
safe_style = attr(style)
|
safe_style = attr(style)
|
||||||
return trusted(
|
return trusted(
|
||||||
f'<button type="button" class="btn btn-{safe_style} rounded-pill copy-button" data-copy="{safe_value}" '
|
f'<button type="button" class="btn btn-{safe_style} rounded-pill copy-button" data-copy="{safe_value}" '
|
||||||
f'aria-label="Copy {safe_label}">{_svg_icon("clipboard")}<span data-copy-label>{safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
|
f'aria-label="Copy {safe_label}">{_svg_icon("clipboard")}'
|
||||||
|
f'<span data-copy-label>{safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _copy_icon_button(label: str, value: str) -> SafeHtml:
|
def _copy_icon_button(label: str, value: str) -> SafeHtml:
|
||||||
safe_label = escape(label)
|
safe_label = escape(label)
|
||||||
safe_value = attr(value)
|
safe_value = attr(value)
|
||||||
|
button_attrs = (
|
||||||
|
f'type="button" class="copy-icon-button" data-copy="{safe_value}" '
|
||||||
|
f'aria-label="Copy {safe_label}" title="Copy"'
|
||||||
|
)
|
||||||
return trusted(
|
return trusted(
|
||||||
f'<button type="button" class="copy-icon-button" data-copy="{safe_value}" aria-label="Copy {safe_label}" title="Copy">'
|
f"<button {button_attrs}>"
|
||||||
f'{_svg_icon("clipboard")}<span class="visually-hidden" data-copy-label>Copy {safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
|
f'{_svg_icon("clipboard")}<span class="visually-hidden" data-copy-label>Copy {safe_label}</span>'
|
||||||
|
f'{_svg_icon("check", "check-icon")}</button>'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -83,9 +135,14 @@ def _copy_menu(label: str, items: list[tuple[str, str]]) -> SafeHtml:
|
|||||||
safe_label = escape(label)
|
safe_label = escape(label)
|
||||||
buttons = []
|
buttons = []
|
||||||
for item_label, item_value in items:
|
for item_label, item_value in items:
|
||||||
|
button_attrs = (
|
||||||
|
f'type="button" class="copy-menu-item" data-copy="{attr(item_value)}" '
|
||||||
|
f'aria-label="Copy {attr(item_label)}"'
|
||||||
|
)
|
||||||
buttons.append(
|
buttons.append(
|
||||||
f'<button type="button" class="copy-menu-item" data-copy="{attr(item_value)}" aria-label="Copy {attr(item_label)}">'
|
f"<button {button_attrs}>"
|
||||||
f'{_svg_icon("clipboard")}<span data-copy-label>{escape(item_label)}</span>{_svg_icon("check", "check-icon")}</button>'
|
f'{_svg_icon("clipboard")}<span data-copy-label>{escape(item_label)}</span>'
|
||||||
|
f'{_svg_icon("check", "check-icon")}</button>'
|
||||||
)
|
)
|
||||||
return trusted(f"""
|
return trusted(f"""
|
||||||
<details class="copy-menu">
|
<details class="copy-menu">
|
||||||
@@ -106,7 +163,7 @@ def _code_window(
|
|||||||
code_lines = "\n".join(f"<code>{escape(line)}</code>" for line in commands.splitlines())
|
code_lines = "\n".join(f"<code>{escape(line)}</code>" for line in commands.splitlines())
|
||||||
copy_value = copy_value if copy_value is not None else _code_copy_value(commands)
|
copy_value = copy_value if copy_value is not None else _code_copy_value(commands)
|
||||||
return trusted(f"""
|
return trusted(f"""
|
||||||
<div class="terminal-card code-window{safe_class}">
|
<div class="terminal-card code-window{safe_class}" data-slot="code-window">
|
||||||
{_copy_icon_button(copy_label, copy_value)}
|
{_copy_icon_button(copy_label, copy_value)}
|
||||||
{code_lines}
|
{code_lines}
|
||||||
</div>
|
</div>
|
||||||
@@ -115,10 +172,14 @@ def _code_window(
|
|||||||
|
|
||||||
def _code_copy_value(commands: str) -> str:
|
def _code_copy_value(commands: str) -> str:
|
||||||
lines = commands.splitlines()
|
lines = commands.splitlines()
|
||||||
prompt_lines = [line[2:] for line in lines if line.startswith("$ ")]
|
prompt_lines = [_code_copy_line(line) for line in lines if line.startswith("$ ")]
|
||||||
if prompt_lines:
|
if prompt_lines:
|
||||||
return "\n".join(prompt_lines)
|
return "\n".join(prompt_lines)
|
||||||
return "\n".join(line[2:] if line.startswith("$ ") else line for line in lines)
|
return "\n".join(_code_copy_line(line) for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _code_copy_line(line: str) -> str:
|
||||||
|
return line[2:] if line.startswith("$ ") else line
|
||||||
|
|
||||||
|
|
||||||
def _example_update_ips(request_ip: str | None = None) -> tuple[str, str]:
|
def _example_update_ips(request_ip: str | None = None) -> tuple[str, str]:
|
||||||
@@ -164,12 +225,17 @@ def _known_request_ip(request_ip: str | None = None) -> tuple[int | None, str]:
|
|||||||
|
|
||||||
def _manual_update_panel(commands: str) -> SafeHtml:
|
def _manual_update_panel(commands: str) -> SafeHtml:
|
||||||
return trusted(f"""
|
return trusted(f"""
|
||||||
<div class="card card-body gap-3 mt-4">
|
<div class="card card-body gap-3 mt-4" data-slot="card">
|
||||||
<div>
|
<div data-slot="card-header">
|
||||||
<h3>Manual updates</h3>
|
<h3>Manual updates</h3>
|
||||||
<p class="section-copy">FRITZ!Box fills in the IP placeholders automatically. Scripts can use curl. If no IP is sent, KarlDNS uses the request IP.</p>
|
<p class="section-copy">
|
||||||
|
FRITZ!Box fills in the IP placeholders automatically. Scripts can use curl.
|
||||||
|
If no IP is sent, KarlDNS uses the request IP.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div data-slot="card-content">
|
||||||
|
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
|
||||||
</div>
|
</div>
|
||||||
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
|
|
||||||
</div>
|
</div>
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -194,10 +260,14 @@ def _secret_notice(*, include_registration: bool = False) -> SafeHtml:
|
|||||||
"Update URL, register URL, and dashboard link" if include_registration else "Update URL and dashboard link"
|
"Update URL, register URL, and dashboard link" if include_registration else "Update URL and dashboard link"
|
||||||
)
|
)
|
||||||
return trusted(f"""
|
return trusted(f"""
|
||||||
<div class="alert alert-info my-3 py-2 small" role="note">
|
<div class="alert alert-info ui-alert my-3 py-2 small" role="note" data-slot="alert">
|
||||||
<strong>Public:</strong> Hostname and CNAME target can be shared.
|
<span class="ui-badge ui-badge-public">Public:</span>
|
||||||
<strong>Secret:</strong> {escape(secret_items)} can update or manage this hostname.
|
<span>Hostname and CNAME target can be shared.</span>
|
||||||
Secrets are hidden on screen. Copy still copies the real value.
|
<span class="ui-badge ui-badge-secret">Secret:</span>
|
||||||
|
<span>
|
||||||
|
{escape(secret_items)} can update or manage this hostname. Secrets are hidden on screen.
|
||||||
|
Copy still copies the real value.
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -206,7 +276,8 @@ def _message_band(message: str | None) -> SafeHtml:
|
|||||||
if not message:
|
if not message:
|
||||||
return trusted("")
|
return trusted("")
|
||||||
return trusted(f"""
|
return trusted(f"""
|
||||||
<div class="alert alert-warning mb-0 rounded-0 border-start-0 border-end-0" role="status">
|
<div class="alert alert-warning ui-alert mb-0 rounded-0 border-start-0 border-end-0"
|
||||||
|
role="status" data-slot="alert">
|
||||||
<div class="container">{escape(message)}</div>
|
<div class="container">{escape(message)}</div>
|
||||||
</div>
|
</div>
|
||||||
""")
|
""")
|
||||||
@@ -22,7 +22,8 @@ def _svg_icon(name: str, css_class: str = "copy-icon") -> SafeHtml:
|
|||||||
safe_class = attr(f"lucide-icon {css_class}")
|
safe_class = attr(f"lucide-icon {css_class}")
|
||||||
return trusted(
|
return trusted(
|
||||||
f'<svg class="{safe_class}" aria-hidden="true" focusable="false" width="16" height="16" viewBox="0 0 24 24" '
|
f'<svg class="{safe_class}" aria-hidden="true" focusable="false" width="16" height="16" viewBox="0 0 24 24" '
|
||||||
f'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{path}</svg>'
|
f'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" '
|
||||||
|
f'stroke-linejoin="round">{path}</svg>'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+107
-65
@@ -1,10 +1,27 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from .html_utils import SafeHtml, attr, escape, trusted
|
from .html_utils import SafeHtml, attr, escape, trusted
|
||||||
from .icons import _svg_icon
|
from .icons import _svg_icon
|
||||||
|
|
||||||
|
DEFAULT_DESCRIPTION = (
|
||||||
|
"KarlDNS is a small self-hosted Dynamic DNS service for FRITZ!Box routers, " "Linux cron jobs, and home networks."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PageOptions:
|
||||||
|
extra_styles: SafeHtml = field(default_factory=lambda: trusted(""))
|
||||||
|
extra_scripts: SafeHtml = field(default_factory=lambda: trusted(""))
|
||||||
|
description: str = DEFAULT_DESCRIPTION
|
||||||
|
preload_logo: bool = False
|
||||||
|
canonical_url: str | None = None
|
||||||
|
image_url: str | None = None
|
||||||
|
image_alt: str | None = None
|
||||||
|
structured_data: dict[str, object] | None = None
|
||||||
|
|
||||||
|
|
||||||
def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtml:
|
def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtml:
|
||||||
account_html = ""
|
account_html = ""
|
||||||
@@ -22,8 +39,11 @@ def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtm
|
|||||||
<a class="skip-link" href="#main">Skip to content</a>
|
<a class="skip-link" href="#main">Skip to content</a>
|
||||||
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
|
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand brand" href="/"><img src="/logo-nav.webp?v=3" alt="" width="28" height="28" decoding="async">KarlDNS</a>
|
<a class="navbar-brand brand" href="/">
|
||||||
<div class="navbar-nav site-nav-actions ms-auto flex-row align-items-center justify-content-end gap-2 gap-sm-3">
|
<img src="/logo-nav.webp?v=3" alt="" width="28" height="28" decoding="async">KarlDNS
|
||||||
|
</a>
|
||||||
|
<div class="navbar-nav site-nav-actions ms-auto flex-row align-items-center justify-content-end
|
||||||
|
gap-2 gap-sm-3">
|
||||||
{links}
|
{links}
|
||||||
{account_html}
|
{account_html}
|
||||||
{theme_toggle}
|
{theme_toggle}
|
||||||
@@ -68,60 +88,11 @@ def _site_footer() -> SafeHtml:
|
|||||||
def _page(
|
def _page(
|
||||||
title: str,
|
title: str,
|
||||||
body: SafeHtml,
|
body: SafeHtml,
|
||||||
extra_styles: SafeHtml | None = None,
|
options: PageOptions | None = None,
|
||||||
extra_scripts: SafeHtml | None = None,
|
|
||||||
description: str = "KarlDNS is a small self-hosted Dynamic DNS service for FRITZ!Box routers, Linux cron jobs, and home networks.",
|
|
||||||
preload_logo: bool = False,
|
|
||||||
canonical_url: str | None = None,
|
|
||||||
image_url: str | None = None,
|
|
||||||
image_alt: str | None = None,
|
|
||||||
structured_data: dict[str, object] | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
if not isinstance(body, SafeHtml):
|
if not isinstance(body, SafeHtml):
|
||||||
raise TypeError("body must be a SafeHtml fragment built by a template helper")
|
raise TypeError("body must be a SafeHtml fragment built by a template helper")
|
||||||
extra_styles_html = extra_styles or trusted("")
|
options = options or PageOptions()
|
||||||
extra_scripts_html = extra_scripts or trusted("")
|
|
||||||
preload_logo_html = (
|
|
||||||
trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">')
|
|
||||||
if preload_logo
|
|
||||||
else trusted("")
|
|
||||||
)
|
|
||||||
canonical_html = trusted(f'<link rel="canonical" href="{attr(canonical_url)}">') if canonical_url else trusted("")
|
|
||||||
social_url = canonical_url or ""
|
|
||||||
social_image = image_url or ""
|
|
||||||
social_html = trusted("")
|
|
||||||
if social_url:
|
|
||||||
social_card = "summary_large_image" if social_image else "summary"
|
|
||||||
social_image_alt = image_alt or title
|
|
||||||
social_image_html = (
|
|
||||||
f"""
|
|
||||||
<meta property="og:image" content="{attr(social_image)}">
|
|
||||||
<meta property="og:image:width" content="1200">
|
|
||||||
<meta property="og:image:height" content="630">
|
|
||||||
<meta property="og:image:alt" content="{attr(social_image_alt)}">
|
|
||||||
<meta name="twitter:image" content="{attr(social_image)}">
|
|
||||||
<meta name="twitter:image:alt" content="{attr(social_image_alt)}">
|
|
||||||
"""
|
|
||||||
if social_image
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
social_html = trusted(f"""
|
|
||||||
<meta property="og:type" content="website">
|
|
||||||
<meta property="og:title" content="{attr(title)}">
|
|
||||||
<meta property="og:description" content="{attr(description)}">
|
|
||||||
<meta property="og:url" content="{attr(social_url)}">
|
|
||||||
{social_image_html}
|
|
||||||
<meta name="twitter:card" content="{attr(social_card)}">
|
|
||||||
<meta name="twitter:title" content="{attr(title)}">
|
|
||||||
<meta name="twitter:description" content="{attr(description)}">
|
|
||||||
""")
|
|
||||||
structured_data_html = trusted("")
|
|
||||||
if structured_data:
|
|
||||||
structured_data_html = trusted(
|
|
||||||
'<script type="application/ld+json">'
|
|
||||||
+ escape(json.dumps(structured_data, ensure_ascii=False, separators=(",", ":")))
|
|
||||||
+ "</script>"
|
|
||||||
)
|
|
||||||
return f"""
|
return f"""
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -129,25 +100,96 @@ def _page(
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#ffffff">
|
||||||
<meta name="description" content="{attr(description)}">
|
<meta name="description" content="{attr(options.description)}">
|
||||||
{canonical_html}
|
{_canonical_tag(options.canonical_url)}
|
||||||
{social_html}
|
{_social_meta_tags(
|
||||||
|
title=title,
|
||||||
|
description=options.description,
|
||||||
|
canonical_url=options.canonical_url,
|
||||||
|
image_url=options.image_url,
|
||||||
|
image_alt=options.image_alt,
|
||||||
|
)}
|
||||||
<title>{escape(title)}</title>
|
<title>{escape(title)}</title>
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||||
<link rel="manifest" href="/site.webmanifest">
|
<link rel="manifest" href="/site.webmanifest">
|
||||||
{preload_logo_html}
|
{_logo_preload_tag(options.preload_logo)}
|
||||||
<script src="/theme.js?v=2"></script>
|
{_base_script_tags()}
|
||||||
<script src="/clipboard.js?v=3" defer></script>
|
{options.extra_scripts}
|
||||||
<script src="/visitor-ip.js?v=1" defer></script>
|
{_base_style_tags()}
|
||||||
{extra_scripts_html}
|
{options.extra_styles}
|
||||||
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
|
{_structured_data_tag(options.structured_data)}
|
||||||
<link href="/app.css?v=13" rel="stylesheet">
|
|
||||||
{extra_styles_html}
|
|
||||||
{structured_data_html}
|
|
||||||
</head>
|
</head>
|
||||||
<body>{body}{_site_footer()}</body>
|
<body>{body}{_site_footer()}</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_tag(canonical_url: str | None) -> SafeHtml:
|
||||||
|
return trusted(f'<link rel="canonical" href="{attr(canonical_url)}">') if canonical_url else trusted("")
|
||||||
|
|
||||||
|
|
||||||
|
def _logo_preload_tag(preload_logo: bool) -> SafeHtml:
|
||||||
|
if not preload_logo:
|
||||||
|
return trusted("")
|
||||||
|
return trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">')
|
||||||
|
|
||||||
|
|
||||||
|
def _base_script_tags() -> SafeHtml:
|
||||||
|
return trusted("""
|
||||||
|
<script src="/theme.js?v=2"></script>
|
||||||
|
<script src="/clipboard.js?v=3" defer></script>
|
||||||
|
<script src="/visitor-ip.js?v=1" defer></script>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _base_style_tags() -> SafeHtml:
|
||||||
|
return trusted("""
|
||||||
|
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
|
||||||
|
<link href="/app.css?v=14" rel="stylesheet">
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _social_meta_tags(
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
|
canonical_url: str | None,
|
||||||
|
image_url: str | None,
|
||||||
|
image_alt: str | None,
|
||||||
|
) -> SafeHtml:
|
||||||
|
if not canonical_url:
|
||||||
|
return trusted("")
|
||||||
|
social_card = "summary_large_image" if image_url else "summary"
|
||||||
|
return trusted(f"""
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:title" content="{attr(title)}">
|
||||||
|
<meta property="og:description" content="{attr(description)}">
|
||||||
|
<meta property="og:url" content="{attr(canonical_url)}">
|
||||||
|
{_social_image_tags(image_url, image_alt or title)}
|
||||||
|
<meta name="twitter:card" content="{attr(social_card)}">
|
||||||
|
<meta name="twitter:title" content="{attr(title)}">
|
||||||
|
<meta name="twitter:description" content="{attr(description)}">
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _social_image_tags(image_url: str | None, image_alt: str) -> SafeHtml:
|
||||||
|
if not image_url:
|
||||||
|
return trusted("")
|
||||||
|
return trusted(f"""
|
||||||
|
<meta property="og:image" content="{attr(image_url)}">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta property="og:image:alt" content="{attr(image_alt)}">
|
||||||
|
<meta name="twitter:image" content="{attr(image_url)}">
|
||||||
|
<meta name="twitter:image:alt" content="{attr(image_alt)}">
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _structured_data_tag(structured_data: dict[str, object] | None) -> SafeHtml:
|
||||||
|
if not structured_data:
|
||||||
|
return trusted("")
|
||||||
|
payload = json.dumps(structured_data, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
return trusted('<script type="application/ld+json">' + escape(payload) + "</script>")
|
||||||
+1
-280
@@ -1,282 +1,3 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
THEME_JS = """
|
"""JavaScript is served from package assets by karldns.routers.assets."""
|
||||||
(() => {
|
|
||||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
|
||||||
const storageKey = "karldns-theme";
|
|
||||||
const systemTheme = () => (media.matches ? "dark" : "light");
|
|
||||||
const storedTheme = () => {
|
|
||||||
try {
|
|
||||||
return window.localStorage.getItem(storageKey);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const setStoredTheme = (theme) => {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(storageKey, theme);
|
|
||||||
} catch (_) {}
|
|
||||||
};
|
|
||||||
const syncToggle = (theme) => {
|
|
||||||
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
|
|
||||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
|
||||||
button.dataset.themeNext = nextTheme;
|
|
||||||
button.setAttribute("aria-label", `Switch to ${nextTheme.toLowerCase()} theme`);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const applyTheme = (requestedTheme = storedTheme() || systemTheme()) => {
|
|
||||||
const theme = requestedTheme === "dark" ? "dark" : "light";
|
|
||||||
document.documentElement.setAttribute("data-bs-theme", theme);
|
|
||||||
syncToggle(theme);
|
|
||||||
return theme;
|
|
||||||
};
|
|
||||||
applyTheme();
|
|
||||||
media.addEventListener("change", () => {
|
|
||||||
if (!storedTheme()) applyTheme();
|
|
||||||
});
|
|
||||||
document.addEventListener("DOMContentLoaded", () => syncToggle(document.documentElement.getAttribute("data-bs-theme") || "light"));
|
|
||||||
document.addEventListener("click", (event) => {
|
|
||||||
const toggle = event.target.closest("[data-theme-toggle]");
|
|
||||||
if (!toggle) return;
|
|
||||||
const currentTheme = document.documentElement.getAttribute("data-bs-theme") || systemTheme();
|
|
||||||
const nextTheme = currentTheme === "dark" ? "light" : "dark";
|
|
||||||
setStoredTheme(nextTheme);
|
|
||||||
applyTheme(nextTheme);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
""".strip()
|
|
||||||
|
|
||||||
|
|
||||||
CLIPBOARD_JS = """
|
|
||||||
document.addEventListener("click", async (event) => {
|
|
||||||
document.querySelectorAll("details.copy-menu[open]").forEach((menu) => {
|
|
||||||
if (!menu.contains(event.target)) menu.removeAttribute("open");
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggle = event.target.closest("[data-secret-toggle]");
|
|
||||||
if (toggle) {
|
|
||||||
const group = toggle.closest(".copy-field");
|
|
||||||
const field = group ? group.querySelector("[data-secret-input], [data-secret-value]") : null;
|
|
||||||
if (!field) return;
|
|
||||||
if (field.matches("[data-secret-input]")) {
|
|
||||||
const masked = field.type === "password";
|
|
||||||
field.type = masked ? "text" : "password";
|
|
||||||
toggle.textContent = masked ? "Hide" : "View";
|
|
||||||
toggle.setAttribute("aria-label", masked ? "Hide secret value" : "Show secret value");
|
|
||||||
} else {
|
|
||||||
const masked = field.dataset.secretMasked !== "false";
|
|
||||||
field.dataset.secretMasked = masked ? "false" : "true";
|
|
||||||
field.textContent = masked ? (field.dataset.secretValue || "") : "********";
|
|
||||||
toggle.textContent = masked ? "Hide" : "View";
|
|
||||||
toggle.setAttribute("aria-label", masked ? "Hide secret value" : "Show secret value");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const button = event.target.closest("[data-copy]");
|
|
||||||
if (!button) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(button.dataset.copy || "");
|
|
||||||
const label = button.querySelector("[data-copy-label]");
|
|
||||||
const oldLabel = label ? label.textContent : "";
|
|
||||||
button.classList.add("is-copied");
|
|
||||||
if (label) label.textContent = "Copied";
|
|
||||||
const menu = button.closest("details.copy-menu");
|
|
||||||
if (menu) {
|
|
||||||
setTimeout(() => {
|
|
||||||
menu.removeAttribute("open");
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
button.classList.remove("is-copied");
|
|
||||||
if (label) label.textContent = oldLabel;
|
|
||||||
}, 1200);
|
|
||||||
} catch (_) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key !== "Escape") return;
|
|
||||||
document.querySelectorAll("details.copy-menu[open]").forEach((menu) => {
|
|
||||||
menu.removeAttribute("open");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
""".strip()
|
|
||||||
|
|
||||||
|
|
||||||
ADMIN_TABLES_JS = """
|
|
||||||
document.querySelectorAll("[data-admin-table]").forEach((tableShell) => {
|
|
||||||
const table = tableShell.querySelector("table");
|
|
||||||
const tbody = tableShell.querySelector("tbody");
|
|
||||||
const search = tableShell.querySelector("[data-table-search]");
|
|
||||||
const pageSize = tableShell.querySelector("[data-table-page-size]");
|
|
||||||
const count = tableShell.querySelector("[data-table-count]");
|
|
||||||
const pagination = tableShell.querySelector("[data-table-pagination]");
|
|
||||||
if (!table || !tbody || !search || !pageSize || !count || !pagination) return;
|
|
||||||
|
|
||||||
const headerCount = table.querySelectorAll("thead th").length || 1;
|
|
||||||
const originalRows = Array.from(tbody.querySelectorAll("tr"));
|
|
||||||
const dataRows = originalRows.filter((row) => !row.classList.contains("empty-row"));
|
|
||||||
let currentPage = 1;
|
|
||||||
|
|
||||||
const emptyRow = (message) => {
|
|
||||||
const row = document.createElement("tr");
|
|
||||||
row.className = "empty-row";
|
|
||||||
const cell = document.createElement("td");
|
|
||||||
cell.className = "empty";
|
|
||||||
cell.colSpan = headerCount;
|
|
||||||
cell.textContent = message;
|
|
||||||
row.append(cell);
|
|
||||||
return row;
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibleRows = () => {
|
|
||||||
const query = search.value.trim().toLowerCase();
|
|
||||||
if (!query) return dataRows;
|
|
||||||
return dataRows.filter((row) => row.textContent.toLowerCase().includes(query));
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderButton = (label, page, disabled = false, active = false) => {
|
|
||||||
const item = document.createElement("li");
|
|
||||||
item.className = `page-item${disabled ? " disabled" : ""}${active ? " active" : ""}`;
|
|
||||||
const button = document.createElement("button");
|
|
||||||
button.type = "button";
|
|
||||||
button.className = "page-link";
|
|
||||||
button.textContent = label;
|
|
||||||
button.disabled = disabled;
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
currentPage = page;
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
item.append(button);
|
|
||||||
return item;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderPagination = (totalPages) => {
|
|
||||||
pagination.replaceChildren();
|
|
||||||
if (totalPages <= 1) return;
|
|
||||||
pagination.append(renderButton("Previous", Math.max(1, currentPage - 1), currentPage === 1));
|
|
||||||
for (let page = 1; page <= totalPages; page += 1) {
|
|
||||||
if (totalPages > 7 && page !== 1 && page !== totalPages && Math.abs(page - currentPage) > 1) {
|
|
||||||
if (page === 2 || page === totalPages - 1) pagination.append(renderButton("...", currentPage, true));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
pagination.append(renderButton(String(page), page, false, page === currentPage));
|
|
||||||
}
|
|
||||||
pagination.append(renderButton("Next", Math.min(totalPages, currentPage + 1), currentPage === totalPages));
|
|
||||||
};
|
|
||||||
|
|
||||||
const render = () => {
|
|
||||||
const rows = visibleRows();
|
|
||||||
const size = Number.parseInt(pageSize.value, 10) || 25;
|
|
||||||
const totalPages = Math.max(1, Math.ceil(rows.length / size));
|
|
||||||
currentPage = Math.min(currentPage, totalPages);
|
|
||||||
const start = (currentPage - 1) * size;
|
|
||||||
const pageRows = rows.slice(start, start + size);
|
|
||||||
tbody.replaceChildren(...(pageRows.length ? pageRows : [emptyRow(dataRows.length ? "No matching rows." : "No rows yet.")]));
|
|
||||||
const shownStart = rows.length ? start + 1 : 0;
|
|
||||||
const shownEnd = rows.length ? Math.min(start + size, rows.length) : 0;
|
|
||||||
count.textContent = `${shownStart}-${shownEnd} of ${rows.length}`;
|
|
||||||
renderPagination(totalPages);
|
|
||||||
};
|
|
||||||
|
|
||||||
search.addEventListener("input", () => {
|
|
||||||
currentPage = 1;
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
pageSize.addEventListener("change", () => {
|
|
||||||
currentPage = 1;
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
""".strip()
|
|
||||||
|
|
||||||
|
|
||||||
VISITOR_IP_JS = """
|
|
||||||
(() => {
|
|
||||||
const ipv4Placeholder = "203.0.113.10";
|
|
||||||
const ipv6Placeholder = "2001:db8::10";
|
|
||||||
const needsIpv4 = () => document.body.textContent.includes(ipv4Placeholder) || document.querySelector(`[data-copy*="${ipv4Placeholder}"]`);
|
|
||||||
const needsIpv6 = () => document.body.textContent.includes(ipv6Placeholder) || document.querySelector(`[data-copy*="${ipv6Placeholder}"]`);
|
|
||||||
|
|
||||||
const fetchIp = async (url) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, { cache: "no-store", mode: "cors" });
|
|
||||||
if (!response.ok) return "";
|
|
||||||
const payload = await response.json();
|
|
||||||
return typeof payload.ip === "string" ? payload.ip : "";
|
|
||||||
} catch (_) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const replaceInTextNodes = (search, replacement) => {
|
|
||||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
||||||
const nodes = [];
|
|
||||||
while (walker.nextNode()) {
|
|
||||||
if (walker.currentNode.nodeValue.includes(search)) nodes.push(walker.currentNode);
|
|
||||||
}
|
|
||||||
nodes.forEach((node) => {
|
|
||||||
node.nodeValue = node.nodeValue.split(search).join(replacement);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const replaceInCopyValues = (search, replacement) => {
|
|
||||||
document.querySelectorAll("[data-copy]").forEach((element) => {
|
|
||||||
const value = element.dataset.copy || "";
|
|
||||||
if (value.includes(search)) element.dataset.copy = value.split(search).join(replacement);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const replaceEverywhere = (search, replacement) => {
|
|
||||||
if (!replacement || replacement === search) return;
|
|
||||||
replaceInTextNodes(search, replacement);
|
|
||||||
replaceInCopyValues(search, replacement);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateCopyCommand = (button, ipv6Command) => {
|
|
||||||
const value = button.dataset.copy || "";
|
|
||||||
if (!value || value.includes("?myipv6=")) return;
|
|
||||||
const lines = value.split("\\n");
|
|
||||||
const output = [];
|
|
||||||
let inserted = false;
|
|
||||||
lines.forEach((line) => {
|
|
||||||
output.push(line);
|
|
||||||
if (!inserted && line.includes("/api/v1/router/update/") && line.includes("?myip=")) {
|
|
||||||
output.push(ipv6Command);
|
|
||||||
inserted = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (inserted) button.dataset.copy = output.join("\\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
const insertIpv6Examples = (ip) => {
|
|
||||||
if (!ip) return;
|
|
||||||
document.querySelectorAll(".terminal-card.manual-code").forEach((card) => {
|
|
||||||
if (card.textContent.includes("?myipv6=")) return;
|
|
||||||
const lines = [...card.querySelectorAll("code")];
|
|
||||||
const ipv4Line = lines.find((line) => line.textContent.includes("/api/v1/router/update/") && line.textContent.includes("?myip="));
|
|
||||||
if (!ipv4Line) return;
|
|
||||||
const base = ipv4Line.textContent.split("?myip=", 1)[0].replace(/'$/, "");
|
|
||||||
const ipv6Command = `${base}?myipv6=${ip}'`;
|
|
||||||
const code = document.createElement("code");
|
|
||||||
code.textContent = ipv6Command;
|
|
||||||
ipv4Line.after(code);
|
|
||||||
const copyButton = card.querySelector("[data-copy]");
|
|
||||||
if (copyButton) updateCopyCommand(copyButton, ipv6Command);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", async () => {
|
|
||||||
const tasks = [];
|
|
||||||
if (needsIpv4()) tasks.push(fetchIp("https://api4.ipify.org?format=json").then((ip) => replaceEverywhere(ipv4Placeholder, ip)));
|
|
||||||
if (needsIpv6() || document.querySelector(".terminal-card.manual-code")) {
|
|
||||||
tasks.push(fetchIp("https://api6.ipify.org?format=json").then((ip) => {
|
|
||||||
replaceEverywhere(ipv6Placeholder, ip);
|
|
||||||
insertIpv6Examples(ip);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
await Promise.allSettled(tasks);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
""".strip()
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from __future__ import annotations
|
||||||
@@ -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>
|
||||||
|
""")
|
||||||
@@ -2,10 +2,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
from starlette.responses import Response
|
||||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||||
|
|
||||||
from ..security import client_ip, is_rate_limited_path, rate_limit_bucket
|
from ..security import client_ip, is_rate_limited_path, rate_limit_bucket
|
||||||
@@ -13,6 +15,12 @@ from ..settings import DdnsSettings
|
|||||||
from ..store import DdnsStore
|
from ..store import DdnsStore
|
||||||
|
|
||||||
ACCESS_LOGGER = logging.getLogger("karldns.access")
|
ACCESS_LOGGER = logging.getLogger("karldns.access")
|
||||||
|
SECRET_RESPONSE_PREFIXES = (
|
||||||
|
"/m/",
|
||||||
|
"/api/v1/hostnames",
|
||||||
|
"/api/v1/router/",
|
||||||
|
"/api/v1/admin/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RequestBodyTooLargeError(Exception):
|
class RequestBodyTooLargeError(Exception):
|
||||||
@@ -49,7 +57,7 @@ class RequestBodyLimitMiddleware:
|
|||||||
|
|
||||||
def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None:
|
def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None:
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def security_middleware(request: Request, call_next):
|
async def security_middleware(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
|
||||||
started_at = time.perf_counter()
|
started_at = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
content_length = int(request.headers.get("content-length", "0") or "0")
|
content_length = int(request.headers.get("content-length", "0") or "0")
|
||||||
@@ -102,14 +110,12 @@ def is_admin_path(path: str, settings: DdnsSettings) -> bool:
|
|||||||
|
|
||||||
def is_secret_response(request: Request) -> bool:
|
def is_secret_response(request: Request) -> bool:
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
settings = request.app.state.settings
|
return (
|
||||||
if is_admin_path(path, settings) or path.startswith("/m/"):
|
is_admin_path(path, request.app.state.settings)
|
||||||
return True
|
or path.startswith(SECRET_RESPONSE_PREFIXES)
|
||||||
if path.startswith(("/api/v1/hostnames", "/api/v1/router/")):
|
or request.method == "POST"
|
||||||
return True
|
and path == "/"
|
||||||
if path.startswith("/api/v1/admin/"):
|
)
|
||||||
return True
|
|
||||||
return request.method == "POST" and path == "/"
|
|
||||||
|
|
||||||
|
|
||||||
def is_noindex_response(request: Request) -> bool:
|
def is_noindex_response(request: Request) -> bool:
|
||||||
|
|||||||
+1
-1718
File diff suppressed because it is too large.
Load diff
+270
-114
@@ -1,14 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from ..security import admin_csrf_token
|
from ..security import admin_csrf_token
|
||||||
from ..services.ddns import DdnsService
|
|
||||||
from ..settings import DdnsSettings
|
from ..settings import DdnsSettings
|
||||||
|
from ..store import DdnsStore
|
||||||
from .html_utils import SafeHtml, attr, escape, trusted
|
from .html_utils import SafeHtml, attr, escape, trusted
|
||||||
from .layout import _page, _top_nav
|
from .layout import PageOptions, _page, _top_nav
|
||||||
from .templates_admin_tables import (
|
from .templates_admin_tables import (
|
||||||
_account_row,
|
_account_row,
|
||||||
_admin_table_pagination,
|
_admin_table_pagination,
|
||||||
@@ -17,6 +18,52 @@ from .templates_admin_tables import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _CleanupSummary:
|
||||||
|
last_run: object
|
||||||
|
next_run: object
|
||||||
|
status: object
|
||||||
|
last_error: object
|
||||||
|
interval_seconds: object
|
||||||
|
accounts_removed: object
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AdminPageContext:
|
||||||
|
accounts: Sequence[Mapping[str, object]]
|
||||||
|
cleanup_runs: Sequence[Mapping[str, object]]
|
||||||
|
scheduled_tasks: Sequence[Mapping[str, object]]
|
||||||
|
update_events: Sequence[Mapping[str, object]]
|
||||||
|
service_metrics: dict[str, float]
|
||||||
|
table_counts: dict[str, int]
|
||||||
|
table_offsets: dict[str, int]
|
||||||
|
table_limit: int
|
||||||
|
notice_html: SafeHtml | None
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_page_context(
|
||||||
|
store: DdnsStore,
|
||||||
|
table_offsets: dict[str, int],
|
||||||
|
table_limit: int,
|
||||||
|
notice_html: SafeHtml | None,
|
||||||
|
) -> AdminPageContext:
|
||||||
|
return AdminPageContext(
|
||||||
|
accounts=store.list_accounts(limit=table_limit, offset=table_offsets.get("accounts", 0)),
|
||||||
|
cleanup_runs=store.list_cleanup_runs(limit=table_limit, offset=table_offsets.get("cleanup_runs", 0)),
|
||||||
|
scheduled_tasks=store.list_scheduled_tasks(),
|
||||||
|
update_events=store.list_update_events(table_limit, offset=table_offsets.get("update_events", 0)),
|
||||||
|
service_metrics=store.get_service_metrics(),
|
||||||
|
table_counts={
|
||||||
|
"accounts": store.count_accounts(),
|
||||||
|
"cleanup_runs": store.count_cleanup_runs(),
|
||||||
|
"update_events": store.count_update_events(),
|
||||||
|
},
|
||||||
|
table_offsets=table_offsets,
|
||||||
|
table_limit=table_limit,
|
||||||
|
notice_html=notice_html,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
|
def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
|
||||||
return trusted(f"""
|
return trusted(f"""
|
||||||
<section class="section success-section">
|
<section class="section success-section">
|
||||||
@@ -33,116 +80,193 @@ def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
|
|||||||
|
|
||||||
|
|
||||||
def _render_admin_page(
|
def _render_admin_page(
|
||||||
service: DdnsService,
|
|
||||||
settings: DdnsSettings,
|
settings: DdnsSettings,
|
||||||
accounts: Sequence[Mapping[str, object]],
|
context: AdminPageContext,
|
||||||
cleanup_runs: Sequence[Mapping[str, object]],
|
|
||||||
scheduled_tasks: Sequence[Mapping[str, object]],
|
|
||||||
update_events: Sequence[Mapping[str, object]],
|
|
||||||
service_metrics: dict[str, float],
|
|
||||||
table_counts: dict[str, int],
|
|
||||||
table_offsets: dict[str, int],
|
|
||||||
table_limit: int,
|
|
||||||
notice_html: SafeHtml | None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
csrf = attr(admin_csrf_token(settings))
|
csrf = attr(admin_csrf_token(settings))
|
||||||
admin_path = settings.admin_path
|
admin_path = settings.admin_path
|
||||||
safe_admin_path = attr(admin_path)
|
safe_admin_path = attr(admin_path)
|
||||||
rows = "\n".join(_account_row(account, csrf=csrf, admin_path=admin_path) for account in accounts) or """
|
rows = _account_rows(context.accounts, csrf=csrf, admin_path=admin_path)
|
||||||
<tr class="empty-row"><td colspan="7" class="empty">No hostnames yet.</td></tr>
|
event_rows = _event_rows(context.update_events)
|
||||||
"""
|
accounts_count = escape(context.table_counts.get("accounts", len(context.accounts)))
|
||||||
event_rows = "\n".join(_event_row(event) for event in update_events) or """
|
event_count = escape(context.table_counts.get("update_events", len(context.update_events)))
|
||||||
<tr class="empty-row"><td colspan="7" class="empty">No update events yet.</td></tr>
|
metrics_html = _metrics_panel(context.service_metrics)
|
||||||
"""
|
|
||||||
accounts_count = escape(table_counts.get("accounts", len(accounts)))
|
|
||||||
event_count = escape(table_counts.get("update_events", len(update_events)))
|
|
||||||
metrics_html = _metrics_panel(service_metrics)
|
|
||||||
account_pager = _server_page_links(
|
account_pager = _server_page_links(
|
||||||
admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, table_counts.get("accounts", 0)
|
admin_path,
|
||||||
|
"accounts_offset",
|
||||||
|
context.table_offsets.get("accounts", 0),
|
||||||
|
context.table_limit,
|
||||||
|
context.table_counts.get("accounts", 0),
|
||||||
)
|
)
|
||||||
event_pager = _server_page_links(
|
event_pager = _server_page_links(
|
||||||
admin_path,
|
admin_path,
|
||||||
"update_events_offset",
|
"update_events_offset",
|
||||||
table_offsets.get("update_events", 0),
|
context.table_offsets.get("update_events", 0),
|
||||||
table_limit,
|
context.table_limit,
|
||||||
table_counts.get("update_events", 0),
|
context.table_counts.get("update_events", 0),
|
||||||
)
|
)
|
||||||
cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf)
|
cleanup_panel = _cleanup_panel(settings, context.cleanup_runs, context.scheduled_tasks, safe_admin_path, csrf)
|
||||||
totals_html = _totals_strip(table_counts, service_metrics)
|
totals_html = _totals_strip(context.table_counts, context.service_metrics)
|
||||||
|
title_section = _admin_title_section(totals_html)
|
||||||
|
status_section = _admin_status_section(cleanup_panel, metrics_html)
|
||||||
|
hostnames_section = _admin_hostnames_section(context, rows, accounts_count, account_pager)
|
||||||
|
events_section = _admin_events_section(context, event_rows, event_count, event_pager)
|
||||||
return _page(
|
return _page(
|
||||||
"KarlDNS Admin",
|
"KarlDNS Admin",
|
||||||
trusted(f"""
|
trusted(f"""
|
||||||
<main id="main">
|
<main id="main">
|
||||||
{_top_nav("Operator")}
|
{_top_nav("Operator")}
|
||||||
<section class="admin-console">
|
{title_section}
|
||||||
<div class="container">
|
{context.notice_html or ""}
|
||||||
<div class="admin-page-title">
|
{status_section}
|
||||||
<div>
|
{hostnames_section}
|
||||||
<p class="eyebrow">KarlDNS</p>
|
{events_section}
|
||||||
<h1>Operator console</h1>
|
|
||||||
</div>
|
|
||||||
{totals_html}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{notice_html or ""}
|
|
||||||
|
|
||||||
<section class="admin-block">
|
|
||||||
<div class="container">
|
|
||||||
{cleanup_panel}
|
|
||||||
{metrics_html}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="admin-block">
|
|
||||||
<div class="container">
|
|
||||||
<div class="admin-section-heading">
|
|
||||||
<div>
|
|
||||||
<h2>Hostnames</h2>
|
|
||||||
<p class="section-copy mb-0">{escape(len(accounts))} of {accounts_count} shown.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{_admin_table_toolbar("hostnames", "Search hostnames")}
|
|
||||||
<div class="table-responsive border rounded admin-table-scroll">
|
|
||||||
<table class="table mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr><th scope="col">Hostname</th><th scope="col">Status</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Updated</th><th scope="col">Links</th><th scope="col">Actions</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>{rows}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{_admin_table_pagination()}
|
|
||||||
{account_pager}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="admin-block">
|
|
||||||
<div class="container">
|
|
||||||
<div class="admin-section-heading">
|
|
||||||
<div>
|
|
||||||
<h2>Update events</h2>
|
|
||||||
<p class="section-copy mb-0">{escape(len(update_events))} of {event_count} shown. Unchanged checks are grouped.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{_admin_table_toolbar("update-events", "Search update events")}
|
|
||||||
<div class="table-responsive border rounded admin-table-scroll">
|
|
||||||
<table class="table mb-0">
|
|
||||||
<thead><tr><th scope="col">Time</th><th scope="col">Hostname</th><th scope="col">Status</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Count</th><th scope="col">Detail</th></tr></thead>
|
|
||||||
<tbody>{event_rows}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{_admin_table_pagination()}
|
|
||||||
{event_pager}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
"""),
|
"""),
|
||||||
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
|
PageOptions(
|
||||||
extra_scripts=trusted('<script src="/admin-tables.js?v=2" defer></script>'),
|
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
|
||||||
|
extra_scripts=trusted('<script src="/admin-tables.js?v=3" defer></script>'),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_title_section(totals_html: SafeHtml) -> SafeHtml:
|
||||||
|
return trusted(f"""
|
||||||
|
<section class="admin-console">
|
||||||
|
<div class="container">
|
||||||
|
<div class="admin-page-title">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">KarlDNS</p>
|
||||||
|
<h1>Operator console</h1>
|
||||||
|
</div>
|
||||||
|
{totals_html}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_status_section(cleanup_panel: SafeHtml, metrics_html: SafeHtml) -> SafeHtml:
|
||||||
|
return trusted(f"""
|
||||||
|
<section class="admin-block">
|
||||||
|
<div class="container">
|
||||||
|
{cleanup_panel}
|
||||||
|
{metrics_html}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_hostnames_section(
|
||||||
|
context: AdminPageContext,
|
||||||
|
rows: SafeHtml,
|
||||||
|
accounts_count: str,
|
||||||
|
account_pager: SafeHtml,
|
||||||
|
) -> SafeHtml:
|
||||||
|
return trusted(f"""
|
||||||
|
<section class="admin-block">
|
||||||
|
<div class="container">
|
||||||
|
<div class="admin-section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Hostnames</h2>
|
||||||
|
<p class="section-copy mb-0">{escape(len(context.accounts))} of {accounts_count} shown.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_admin_table_toolbar("hostnames", "Filter visible hostnames")}
|
||||||
|
<div class="table-responsive border rounded admin-table-scroll" data-slot="card">
|
||||||
|
<table class="table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Hostname</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">IPv4</th>
|
||||||
|
<th scope="col">IPv6</th>
|
||||||
|
<th scope="col">Updated</th>
|
||||||
|
<th scope="col">Links</th>
|
||||||
|
<th scope="col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{_admin_table_pagination()}
|
||||||
|
{account_pager}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_events_section(
|
||||||
|
context: AdminPageContext,
|
||||||
|
event_rows: SafeHtml,
|
||||||
|
event_count: str,
|
||||||
|
event_pager: SafeHtml,
|
||||||
|
) -> SafeHtml:
|
||||||
|
return trusted(f"""
|
||||||
|
<section class="admin-block">
|
||||||
|
<div class="container">
|
||||||
|
<div class="admin-section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>Update events</h2>
|
||||||
|
<p class="section-copy mb-0">
|
||||||
|
{escape(len(context.update_events))} of {event_count} shown. Unchanged checks are grouped.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_admin_table_toolbar("update-events", "Filter visible update events")}
|
||||||
|
<div class="table-responsive border rounded admin-table-scroll" data-slot="card">
|
||||||
|
<table class="table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Time</th>
|
||||||
|
<th scope="col">Hostname</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">IPv4</th>
|
||||||
|
<th scope="col">IPv6</th>
|
||||||
|
<th scope="col">Count</th>
|
||||||
|
<th scope="col">Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{event_rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{_admin_table_pagination()}
|
||||||
|
{event_pager}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _account_rows(accounts: Sequence[Mapping[str, object]], *, csrf: str, admin_path: str) -> SafeHtml:
|
||||||
|
return trusted("\n".join(_account_row(account, csrf=csrf, admin_path=admin_path) for account in accounts) or """
|
||||||
|
<tr class="empty-row">
|
||||||
|
<td colspan="7" class="empty">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-title">No hostnames yet.</div>
|
||||||
|
<div class="empty-state-description">
|
||||||
|
Generated hostnames appear here after users create or register them.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _event_rows(events: Sequence[Mapping[str, object]]) -> SafeHtml:
|
||||||
|
return trusted("\n".join(_event_row(event) for event in events) or """
|
||||||
|
<tr class="empty-row">
|
||||||
|
<td colspan="7" class="empty">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-title">No update events yet.</div>
|
||||||
|
<div class="empty-state-description">
|
||||||
|
Router update activity and DNS publish results will appear here.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
def _totals_strip(table_counts: dict[str, int], metrics: dict[str, float]) -> SafeHtml:
|
def _totals_strip(table_counts: dict[str, int], metrics: dict[str, float]) -> SafeHtml:
|
||||||
items = [
|
items = [
|
||||||
("Hostnames", table_counts.get("accounts", 0)),
|
("Hostnames", table_counts.get("accounts", 0)),
|
||||||
@@ -161,7 +285,7 @@ def _admin_notice(kind: str, message: str) -> SafeHtml:
|
|||||||
alert_class = "alert-success" if kind == "success" else "alert-info"
|
alert_class = "alert-success" if kind == "success" else "alert-info"
|
||||||
return trusted(f"""
|
return trusted(f"""
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="alert {alert_class} admin-notice" role="status">{escape(message)}</div>
|
<div class="alert {alert_class} ui-alert admin-notice" role="status" data-slot="alert">{escape(message)}</div>
|
||||||
</div>
|
</div>
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -180,8 +304,10 @@ def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: in
|
|||||||
return trusted(f"""
|
return trusted(f"""
|
||||||
<nav class="d-flex align-items-center justify-content-end gap-2 mt-2" aria-label="Server-side table pages">
|
<nav class="d-flex align-items-center justify-content-end gap-2 mt-2" aria-label="Server-side table pages">
|
||||||
<span class="small text-secondary">{escape(start)}-{escape(end)} of {escape(total)}</span>
|
<span class="small text-secondary">{escape(start)}-{escape(end)} of {escape(total)}</span>
|
||||||
<a class="btn btn-sm btn-light rounded-pill{previous_disabled}" href="{previous_href}" aria-disabled="{str(offset <= 0).lower()}">Previous</a>
|
<a class="btn btn-sm btn-light rounded-pill{previous_disabled}" href="{previous_href}"
|
||||||
<a class="btn btn-sm btn-light rounded-pill{next_disabled}" href="{next_href}" aria-disabled="{str(next_offset >= total).lower()}">Next</a>
|
aria-disabled="{str(offset <= 0).lower()}">Previous</a>
|
||||||
|
<a class="btn btn-sm btn-light rounded-pill{next_disabled}" href="{next_href}"
|
||||||
|
aria-disabled="{str(next_offset >= total).lower()}">Next</a>
|
||||||
</nav>
|
</nav>
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -193,37 +319,31 @@ def _cleanup_panel(
|
|||||||
safe_admin_path: str,
|
safe_admin_path: str,
|
||||||
csrf: str,
|
csrf: str,
|
||||||
) -> SafeHtml:
|
) -> SafeHtml:
|
||||||
task = next(
|
summary = _cleanup_summary(
|
||||||
(item for item in scheduled_tasks if item.get("name") == "cleanup"),
|
cleanup_runs=cleanup_runs,
|
||||||
scheduled_tasks[0] if scheduled_tasks else {},
|
scheduled_tasks=scheduled_tasks,
|
||||||
|
fallback_interval_seconds=settings.cleanup_interval_seconds,
|
||||||
)
|
)
|
||||||
last_run = task.get("last_finished_at") or (cleanup_runs[0].get("created_at") if cleanup_runs else None)
|
|
||||||
next_run = task.get("next_run_at")
|
|
||||||
status = task.get("last_status") or "pending"
|
|
||||||
last_error = task.get("last_error") or ""
|
|
||||||
interval = task.get("interval_seconds") or settings.cleanup_interval_seconds
|
|
||||||
last_cleanup = cleanup_runs[0] if cleanup_runs else {}
|
|
||||||
accounts_removed = last_cleanup.get("unused_accounts_deleted", 0)
|
|
||||||
error_html = (
|
error_html = (
|
||||||
trusted(f'<p class="small text-danger mb-0">Last error: {escape(last_error)}</p>')
|
trusted(f'<p class="small text-danger mb-0">Last error: {escape(summary.last_error)}</p>')
|
||||||
if last_error
|
if summary.last_error
|
||||||
else trusted("")
|
else trusted("")
|
||||||
)
|
)
|
||||||
last_run_title = attr(str(last_run or "Never"))
|
last_run_title = attr(str(summary.last_run or "Never"))
|
||||||
next_run_title = attr(str(next_run or "Pending"))
|
next_run_title = attr(str(summary.next_run or "Pending"))
|
||||||
last_run_display = escape(_format_timestamp(last_run, "Never"))
|
last_run_display = escape(_format_timestamp(summary.last_run, "Never"))
|
||||||
next_run_display = escape(_format_timestamp(next_run, "Pending"))
|
next_run_display = escape(_format_timestamp(summary.next_run, "Pending"))
|
||||||
return trusted(f"""
|
return trusted(f"""
|
||||||
<div class="admin-cleanup-bar">
|
<div class="admin-cleanup-bar">
|
||||||
<div class="admin-cleanup-title">
|
<div class="admin-cleanup-title">
|
||||||
<h2>Cleanup</h2>
|
<h2>Cleanup</h2>
|
||||||
<span>Every {escape(_format_interval(interval))}</span>
|
<span>Every {escape(_format_interval(summary.interval_seconds))}</span>
|
||||||
</div>
|
</div>
|
||||||
<dl class="admin-cleanup-times">
|
<dl class="admin-cleanup-times">
|
||||||
<div><dt>Last run</dt><dd title="{last_run_title}">{last_run_display}</dd></div>
|
<div><dt>Last run</dt><dd title="{last_run_title}">{last_run_display}</dd></div>
|
||||||
<div><dt>Next run</dt><dd title="{next_run_title}">{next_run_display}</dd></div>
|
<div><dt>Next run</dt><dd title="{next_run_title}">{next_run_display}</dd></div>
|
||||||
<div><dt>Status</dt><dd>{escape(str(status))}</dd></div>
|
<div><dt>Status</dt><dd>{escape(str(summary.status))}</dd></div>
|
||||||
<div><dt>Removed</dt><dd>{escape(accounts_removed)}</dd></div>
|
<div><dt>Removed</dt><dd>{escape(summary.accounts_removed)}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
{error_html}
|
{error_html}
|
||||||
<form method="post" action="{safe_admin_path}/cleanup" aria-label="Run cleanup now">
|
<form method="post" action="{safe_admin_path}/cleanup" aria-label="Run cleanup now">
|
||||||
@@ -234,6 +354,39 @@ def _cleanup_panel(
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_task(scheduled_tasks: Sequence[Mapping[str, object]]) -> Mapping[str, object]:
|
||||||
|
return next(
|
||||||
|
(item for item in scheduled_tasks if item.get("name") == "cleanup"),
|
||||||
|
scheduled_tasks[0] if scheduled_tasks else {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_summary(
|
||||||
|
*,
|
||||||
|
cleanup_runs: Sequence[Mapping[str, object]],
|
||||||
|
scheduled_tasks: Sequence[Mapping[str, object]],
|
||||||
|
fallback_interval_seconds: int,
|
||||||
|
) -> _CleanupSummary:
|
||||||
|
task = _cleanup_task(scheduled_tasks)
|
||||||
|
last_cleanup = cleanup_runs[0] if cleanup_runs else {}
|
||||||
|
return _CleanupSummary(
|
||||||
|
last_run=_cleanup_last_run(task, last_cleanup),
|
||||||
|
next_run=task.get("next_run_at"),
|
||||||
|
status=task.get("last_status") or "pending",
|
||||||
|
last_error=task.get("last_error") or "",
|
||||||
|
interval_seconds=task.get("interval_seconds") or fallback_interval_seconds,
|
||||||
|
accounts_removed=_cleanup_accounts_removed(last_cleanup),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_last_run(task: Mapping[str, object], last_cleanup: Mapping[str, object]) -> object:
|
||||||
|
return task.get("last_finished_at") or last_cleanup.get("created_at")
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_accounts_removed(last_cleanup: Mapping[str, object]) -> object:
|
||||||
|
return last_cleanup.get("unused_accounts_deleted", 0)
|
||||||
|
|
||||||
|
|
||||||
def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
|
def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
|
||||||
samples = metrics.get("update_latency_samples_total", 0)
|
samples = metrics.get("update_latency_samples_total", 0)
|
||||||
average_latency = metrics.get("update_latency_ms_total", 0) / samples if samples else 0
|
average_latency = metrics.get("update_latency_ms_total", 0) / samples if samples else 0
|
||||||
@@ -247,7 +400,10 @@ def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
|
|||||||
("Last update time", f"{metrics.get('last_update_latency_ms', 0):.1f} ms"),
|
("Last update time", f"{metrics.get('last_update_latency_ms', 0):.1f} ms"),
|
||||||
(
|
(
|
||||||
"Cleanup ok/errors",
|
"Cleanup ok/errors",
|
||||||
f"{_metric_int(metrics.get('cleanup_success_total', 0))}/{_metric_int(metrics.get('cleanup_errors_total', 0))}",
|
(
|
||||||
|
f"{_metric_int(metrics.get('cleanup_success_total', 0))}/"
|
||||||
|
f"{_metric_int(metrics.get('cleanup_errors_total', 0))}"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
cards = "\n".join(f"""
|
cards = "\n".join(f"""
|
||||||
|
|||||||
@@ -1,12 +1,49 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from .components import _event_label
|
from .components import _event_label
|
||||||
from .html_utils import attr, escape
|
from .html_utils import attr, escape
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _AdminEventRow:
|
||||||
|
created_raw: str
|
||||||
|
hostname_raw: str
|
||||||
|
detail_raw: str
|
||||||
|
created: str
|
||||||
|
hostname: str
|
||||||
|
status: str
|
||||||
|
ipv4: str
|
||||||
|
ipv6: str
|
||||||
|
count: str
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_event_row_view(event: Mapping[str, object]) -> _AdminEventRow:
|
||||||
|
created_raw = _event_text(event, "created_at")
|
||||||
|
hostname_raw = _event_text(event, "hostname")
|
||||||
|
detail_raw = _event_text(event, "detail")
|
||||||
|
return _AdminEventRow(
|
||||||
|
created_raw=created_raw,
|
||||||
|
hostname_raw=hostname_raw,
|
||||||
|
detail_raw=detail_raw,
|
||||||
|
created=escape(_short_time(created_raw)),
|
||||||
|
hostname=escape(hostname_raw),
|
||||||
|
status=escape(_event_label(event.get("status"))),
|
||||||
|
ipv4=escape(_event_text(event, "ipv4")),
|
||||||
|
ipv6=escape(_event_text(event, "ipv6")),
|
||||||
|
count=escape(event.get("count") or 1),
|
||||||
|
detail=escape(detail_raw),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_text(event: Mapping[str, object], key: str) -> str:
|
||||||
|
return str(event.get(key) or "-")
|
||||||
|
|
||||||
|
|
||||||
def _account_row(account: Mapping[str, object], csrf: str = "", admin_path: str = "/admin") -> str:
|
def _account_row(account: Mapping[str, object], csrf: str = "", admin_path: str = "/admin") -> str:
|
||||||
raw_hostname = str(account["hostname"])
|
raw_hostname = str(account["hostname"])
|
||||||
hostname = escape(raw_hostname)
|
hostname = escape(raw_hostname)
|
||||||
@@ -30,15 +67,18 @@ def _account_row(account: Mapping[str, object], csrf: str = "", admin_path: str
|
|||||||
<form method="post" action="{safe_admin_path}/hostnames/rotate" class="admin-action-group">
|
<form method="post" action="{safe_admin_path}/hostnames/rotate" class="admin-action-group">
|
||||||
<input type="hidden" name="csrf" value="{attr(csrf)}">
|
<input type="hidden" name="csrf" value="{attr(csrf)}">
|
||||||
<input type="hidden" name="hostname" value="{hostname_attr}">
|
<input type="hidden" name="hostname" value="{hostname_attr}">
|
||||||
<button name="action" value="update" class="btn btn-sm btn-light rounded-pill" title="Create a new update URL" aria-label="New update URL for {hostname_attr}">Update URL</button>
|
<button name="action" value="update" class="btn btn-sm btn-light rounded-pill"
|
||||||
<button name="action" value="management" class="btn btn-sm btn-light rounded-pill" title="Create a new dashboard link" aria-label="New dashboard link for {hostname_attr}">Dashboard</button>
|
title="Create a new update URL" aria-label="New update URL for {hostname_attr}">Update URL</button>
|
||||||
|
<button name="action" value="management" class="btn btn-sm btn-light rounded-pill"
|
||||||
|
title="Create a new dashboard link" aria-label="New dashboard link for {hostname_attr}">Dashboard</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
<td class="cell-delete">
|
<td class="cell-delete">
|
||||||
<form method="post" action="{safe_admin_path}/hostnames/delete">
|
<form method="post" action="{safe_admin_path}/hostnames/delete">
|
||||||
<input type="hidden" name="csrf" value="{attr(csrf)}">
|
<input type="hidden" name="csrf" value="{attr(csrf)}">
|
||||||
<input type="hidden" name="hostname" value="{hostname_attr}">
|
<input type="hidden" name="hostname" value="{hostname_attr}">
|
||||||
<button class="btn btn-sm btn-outline-danger rounded-pill" title="Delete hostname" aria-label="Delete {hostname_attr}">Delete</button>
|
<button class="btn btn-sm btn-outline-danger rounded-pill" title="Delete hostname"
|
||||||
|
aria-label="Delete {hostname_attr}">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -53,7 +93,8 @@ def _admin_table_toolbar(table_id: str, search_label: str) -> str:
|
|||||||
<div class="d-flex align-items-center justify-content-between gap-3 flex-column flex-md-row mb-3">
|
<div class="d-flex align-items-center justify-content-between gap-3 flex-column flex-md-row mb-3">
|
||||||
<label class="w-100 mb-0" for="{safe_id}-search">
|
<label class="w-100 mb-0" for="{safe_id}-search">
|
||||||
<span class="visually-hidden">{safe_label}</span>
|
<span class="visually-hidden">{safe_label}</span>
|
||||||
<input id="{safe_id}-search" class="form-control" type="search" placeholder="{safe_label}" data-table-search autocomplete="off">
|
<input id="{safe_id}-search" class="form-control" type="search" placeholder="{safe_label}"
|
||||||
|
data-table-search autocomplete="off">
|
||||||
</label>
|
</label>
|
||||||
<div class="d-flex align-items-center gap-2 flex-shrink-0">
|
<div class="d-flex align-items-center gap-2 flex-shrink-0">
|
||||||
<span class="small text-secondary text-nowrap" data-table-count>0-0 of 0</span>
|
<span class="small text-secondary text-nowrap" data-table-count>0-0 of 0</span>
|
||||||
@@ -79,21 +120,13 @@ def _admin_table_pagination() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _event_row(event: Mapping[str, object]) -> str:
|
def _event_row(event: Mapping[str, object]) -> str:
|
||||||
created_raw = str(event.get("created_at") or "-")
|
row = _admin_event_row_view(event)
|
||||||
hostname_raw = str(event.get("hostname") or "-")
|
|
||||||
detail_raw = str(event.get("detail") or "-")
|
|
||||||
created = escape(_short_time(created_raw))
|
|
||||||
hostname = escape(hostname_raw)
|
|
||||||
status = escape(_event_label(event.get("status")))
|
|
||||||
ipv4 = escape(event.get("ipv4") or "-")
|
|
||||||
ipv6 = escape(event.get("ipv6") or "-")
|
|
||||||
count = escape(event.get("count") or 1)
|
|
||||||
detail = escape(detail_raw)
|
|
||||||
return (
|
return (
|
||||||
f'<tr><td class="cell-time" title="{attr(created_raw)}">{created}</td>'
|
f'<tr><td class="cell-time" title="{attr(row.created_raw)}">{row.created}</td>'
|
||||||
f'<td class="cell-hostname" title="{attr(hostname_raw)}">{hostname}</td>'
|
f'<td class="cell-hostname" title="{attr(row.hostname_raw)}">{row.hostname}</td>'
|
||||||
f'<td class="cell-status">{status}</td><td class="cell-ip">{ipv4}</td><td class="cell-ip">{ipv6}</td>'
|
f'<td class="cell-status">{row.status}</td><td class="cell-ip">{row.ipv4}</td>'
|
||||||
f'<td class="cell-count">{count}</td><td class="cell-detail" title="{attr(detail_raw)}">{detail}</td></tr>'
|
f'<td class="cell-ip">{row.ipv6}</td><td class="cell-count">{row.count}</td>'
|
||||||
|
f'<td class="cell-detail" title="{attr(row.detail_raw)}">{row.detail}</td></tr>'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+402
-209
@@ -1,9 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from ..services.ddns import DdnsService
|
from ..services.ddns import DdnsService
|
||||||
from ..settings import DdnsSettings
|
|
||||||
from .components import (
|
from .components import (
|
||||||
_code_window,
|
_code_window,
|
||||||
_copy_button,
|
_copy_button,
|
||||||
@@ -14,229 +15,383 @@ from .components import (
|
|||||||
_secret_notice,
|
_secret_notice,
|
||||||
)
|
)
|
||||||
from .html_utils import SafeHtml, attr, escape, trusted
|
from .html_utils import SafeHtml, attr, escape, trusted
|
||||||
from .layout import _page, _top_nav
|
from .layout import PageOptions, _page, _top_nav
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..settings import DdnsSettings
|
||||||
|
|
||||||
|
|
||||||
def _render_management_page(
|
@dataclass(frozen=True)
|
||||||
service: DdnsService,
|
class ManagementPageContext:
|
||||||
settings: DdnsSettings,
|
management_slug: str
|
||||||
management_slug: str,
|
account: Mapping[str, object]
|
||||||
account: Mapping[str, object],
|
csrf_token: str
|
||||||
csrf_token: str,
|
message: str | None = None
|
||||||
message: str | None = None,
|
request_ip: str | None = None
|
||||||
request_ip: str | None = None,
|
|
||||||
) -> str:
|
@property
|
||||||
account_for_url = {
|
def account_for_url(self) -> dict[str, str]:
|
||||||
"hostname": str(account["hostname"]),
|
return {
|
||||||
"update_slug": str(account["update_slug"]),
|
"hostname": str(self.account["hostname"]),
|
||||||
"management_slug": management_slug,
|
"update_slug": str(self.account["update_slug"]),
|
||||||
}
|
"management_slug": self.management_slug,
|
||||||
update_url = service.fritz_update_url(account_for_url)
|
}
|
||||||
hostname = escape(account["hostname"])
|
|
||||||
ipv4 = escape(account.get("ipv4") or "-")
|
|
||||||
ipv6 = escape(account.get("ipv6") or "-")
|
@dataclass(frozen=True, slots=True)
|
||||||
updated = escape(account.get("updated_at") or "Never")
|
class _ManagementPageView:
|
||||||
fritzbox_fields = _fritzbox_fields(update_url, hostname)
|
update_url: str
|
||||||
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
|
hostname: str
|
||||||
manual_commands = _manual_update_commands(update_url, request_ip)
|
ipv4: str
|
||||||
history_rows = (
|
ipv6: str
|
||||||
"\n".join(
|
updated: str
|
||||||
_user_event_row(event)
|
fritzbox_fields: str
|
||||||
for event in service.store.list_update_events_for_hostname(str(account["hostname"]), 10)
|
cname_record: str
|
||||||
)
|
manual_commands: str
|
||||||
or """
|
|
||||||
<tr><td colspan="6" class="empty">No router updates yet.</td></tr>
|
|
||||||
"""
|
def _management_page_view(update_url: str, context: ManagementPageContext) -> _ManagementPageView:
|
||||||
|
hostname = escape(context.account["hostname"])
|
||||||
|
return _ManagementPageView(
|
||||||
|
update_url=update_url,
|
||||||
|
hostname=hostname,
|
||||||
|
ipv4=escape(context.account.get("ipv4") or "-"),
|
||||||
|
ipv6=escape(context.account.get("ipv6") or "-"),
|
||||||
|
updated=escape(context.account.get("updated_at") or "Never"),
|
||||||
|
fritzbox_fields=_fritzbox_fields(update_url, hostname),
|
||||||
|
cname_record=f"your-subdomain.example.com. CNAME {hostname}.",
|
||||||
|
manual_commands=_manual_update_commands(update_url, context.request_ip),
|
||||||
)
|
)
|
||||||
message_html = _management_message(message)
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PendingManagementPageContext:
|
||||||
|
settings: DdnsSettings
|
||||||
|
management_slug: str
|
||||||
|
account: Mapping[str, object]
|
||||||
|
csrf_token: str
|
||||||
|
message: str | None = None
|
||||||
|
request_ip: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_for_url(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"hostname": str(self.account["hostname"]),
|
||||||
|
"update_slug": str(self.account["update_slug"]),
|
||||||
|
"management_slug": self.management_slug,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_management_page(service: DdnsService, context: ManagementPageContext) -> str:
|
||||||
|
update_url = service.fritz_update_url(context.account_for_url)
|
||||||
|
view = _management_page_view(update_url, context)
|
||||||
|
message_html = _management_message(context.message)
|
||||||
dashboard_password_panel = _dashboard_password_panel(
|
dashboard_password_panel = _dashboard_password_panel(
|
||||||
management_slug, csrf_token, bool(account.get("dashboard_password_hash"))
|
context.management_slug,
|
||||||
|
context.csrf_token,
|
||||||
|
bool(context.account.get("dashboard_password_hash")),
|
||||||
)
|
)
|
||||||
|
dashboard_shell = _management_dashboard_shell(context, view, message_html)
|
||||||
|
history_section = _management_history_section(_management_history_rows(service, context))
|
||||||
|
delete_section = _management_delete_section(context)
|
||||||
return _page(
|
return _page(
|
||||||
"KarlDNS management",
|
"KarlDNS management",
|
||||||
trusted(f"""
|
trusted(f"""
|
||||||
<main id="main" class="dashboard-page">
|
<main id="main" class="dashboard-page">
|
||||||
{_top_nav()}
|
{_top_nav()}
|
||||||
<section class="dashboard-shell">
|
{dashboard_shell}
|
||||||
<div class="container">
|
|
||||||
<div class="dashboard-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Dashboard</p>
|
|
||||||
<h1>{hostname}</h1>
|
|
||||||
<p class="lead">Private management link. Keep it safe.</p>
|
|
||||||
</div>
|
|
||||||
<a class="btn btn-outline-primary rounded-pill" href="/">Home</a>
|
|
||||||
</div>
|
|
||||||
{message_html}
|
|
||||||
<div class="dashboard-grid">
|
|
||||||
<section class="dashboard-card dashboard-card-main" aria-labelledby="router-settings-title">
|
|
||||||
<div class="dashboard-card-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Router settings</p>
|
|
||||||
<h2 id="router-settings-title">Update endpoint</h2>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="/m/{attr(management_slug)}/rotate">
|
|
||||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
|
||||||
<input type="hidden" name="action" value="update">
|
|
||||||
<button class="btn btn-outline-primary rounded-pill" type="submit">New update URL</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<p class="section-copy">Paste the Update-URL into your router. Use the hostname as Domainnamen, or point your own subdomain at it with a CNAME.</p>
|
|
||||||
{_secret_notice()}
|
|
||||||
<div class="dashboard-copy-stack">
|
|
||||||
{_copy_row("Update-URL:", update_url, "Secret")}
|
|
||||||
{_copy_row("Domainnamen:", hostname, "Public")}
|
|
||||||
{_copy_row("CNAME target:", hostname, "Public")}
|
|
||||||
</div>
|
|
||||||
<div class="dashboard-actions">
|
|
||||||
{_copy_button("Router fields", fritzbox_fields)}
|
|
||||||
{_copy_button("Copy CNAME", cname_record, style="outline-secondary")}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="dashboard-card dashboard-status-card" aria-labelledby="status-title">
|
|
||||||
<p class="eyebrow">Current address</p>
|
|
||||||
<h2 id="status-title">Status</h2>
|
|
||||||
<dl class="dashboard-status-list">
|
|
||||||
<div><dt>IPv4</dt><dd>{ipv4}</dd></div>
|
|
||||||
<div><dt>IPv6</dt><dd>{ipv6}</dd></div>
|
|
||||||
<div><dt>Updated</dt><dd>{updated}</dd></div>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<section class="dashboard-card dashboard-card-compact" aria-labelledby="manual-updates-title">
|
|
||||||
<div class="dashboard-card-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Manual updates</p>
|
|
||||||
<h2 id="manual-updates-title">curl examples</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="section-copy">FRITZ!Box fills IP placeholders automatically. Scripts can send an IP explicitly or omit it and let KarlDNS use the request IP.</p>
|
|
||||||
{_code_window(manual_commands, copy_label="manual update commands", class_name="manual-code")}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{dashboard_password_panel}
|
{dashboard_password_panel}
|
||||||
<section class="dashboard-section">
|
{history_section}
|
||||||
<div class="container">
|
{delete_section}
|
||||||
<div class="dashboard-section-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">History</p>
|
|
||||||
<h2>Router updates</h2>
|
|
||||||
<p class="section-copy">Recent IP changes and failed updates for this hostname.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive dashboard-table-shell">
|
|
||||||
<table class="table table-hover dashboard-history-table mb-0">
|
|
||||||
<thead><tr><th>Time</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Count</th><th>Detail</th></tr></thead>
|
|
||||||
<tbody>{history_rows}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="dashboard-section dashboard-danger-section">
|
|
||||||
<div class="container">
|
|
||||||
<div class="dashboard-danger-card">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Delete</p>
|
|
||||||
<h2>Delete hostname</h2>
|
|
||||||
<p class="section-copy">Remove this hostname and its DNS records. The update URL will stop working.</p>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="/m/{attr(management_slug)}/delete">
|
|
||||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
|
||||||
<button class="btn btn-outline-danger rounded-pill" type="submit">Delete hostname</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
"""),
|
"""),
|
||||||
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
|
PageOptions(extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">')),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_pending_management_page(
|
def _management_history_rows(service: DdnsService, context: ManagementPageContext) -> SafeHtml:
|
||||||
service: DdnsService,
|
events = service.store.list_update_events_for_hostname(str(context.account["hostname"]), 10)
|
||||||
settings: DdnsSettings,
|
rows = "\n".join(_user_event_row(event) for event in events)
|
||||||
management_slug: str,
|
return trusted(rows or """
|
||||||
account: Mapping[str, object],
|
<tr class="empty-row">
|
||||||
csrf_token: str,
|
<td colspan="6" class="empty">
|
||||||
message: str | None = None,
|
<div class="empty-state">
|
||||||
request_ip: str | None = None,
|
<div class="empty-state-title">No router updates yet.</div>
|
||||||
) -> str:
|
<div class="empty-state-description">
|
||||||
raw_hostname = str(account["hostname"])
|
This table fills after the router or a script sends its first address update.
|
||||||
account_for_url = {
|
</div>
|
||||||
"hostname": raw_hostname,
|
</div>
|
||||||
"update_slug": str(account["update_slug"]),
|
</td>
|
||||||
"management_slug": management_slug,
|
</tr>
|
||||||
}
|
""")
|
||||||
update_url = service.fritz_update_url(account_for_url)
|
|
||||||
dashboard_url = service.dashboard_url({"management_slug": management_slug})
|
|
||||||
|
def _management_dashboard_shell(
|
||||||
|
context: ManagementPageContext,
|
||||||
|
view: _ManagementPageView,
|
||||||
|
message_html: SafeHtml,
|
||||||
|
) -> SafeHtml:
|
||||||
|
return trusted(f"""
|
||||||
|
<section class="dashboard-shell">
|
||||||
|
<div class="container">
|
||||||
|
{_management_dashboard_header(view)}
|
||||||
|
{message_html}
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
{_router_settings_card(context, view)}
|
||||||
|
{_status_card(view)}
|
||||||
|
</div>
|
||||||
|
{_manual_updates_card(view)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _management_dashboard_header(view: _ManagementPageView) -> SafeHtml:
|
||||||
|
return trusted(f"""
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Dashboard</p>
|
||||||
|
<h1>{view.hostname}</h1>
|
||||||
|
<p class="lead">Private management link. Keep it safe.</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-outline-primary rounded-pill" href="/">Home</a>
|
||||||
|
</div>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _router_settings_card(context: ManagementPageContext, view: _ManagementPageView) -> SafeHtml:
|
||||||
|
return trusted(f"""
|
||||||
|
<section class="dashboard-card dashboard-card-main" data-slot="card" aria-labelledby="router-settings-title">
|
||||||
|
<div class="dashboard-card-header" data-slot="card-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Router settings</p>
|
||||||
|
<h2 id="router-settings-title">Update endpoint</h2>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/m/{attr(context.management_slug)}/rotate">
|
||||||
|
<input type="hidden" name="csrf" value="{attr(context.csrf_token)}">
|
||||||
|
<input type="hidden" name="action" value="update">
|
||||||
|
<button class="btn btn-outline-primary rounded-pill" type="submit">New update URL</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div data-slot="card-content">
|
||||||
|
<p class="section-copy">
|
||||||
|
Paste the Update-URL into your router. Use the hostname as Domainnamen,
|
||||||
|
or point your own subdomain at it with a CNAME.
|
||||||
|
</p>
|
||||||
|
{_secret_notice()}
|
||||||
|
<div class="dashboard-copy-stack">
|
||||||
|
{_copy_row("Update-URL:", view.update_url, "Secret")}
|
||||||
|
{_copy_row("Domainnamen:", view.hostname, "Public")}
|
||||||
|
{_copy_row("CNAME target:", view.hostname, "Public")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-actions" data-slot="card-footer">
|
||||||
|
{_copy_button("Router fields", view.fritzbox_fields)}
|
||||||
|
{_copy_button("Copy CNAME", view.cname_record, style="outline-secondary")}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _status_card(view: _ManagementPageView) -> SafeHtml:
|
||||||
|
return trusted(f"""
|
||||||
|
<section class="dashboard-card dashboard-status-card" data-slot="card" aria-labelledby="status-title">
|
||||||
|
<div data-slot="card-header">
|
||||||
|
<p class="eyebrow">Current address</p>
|
||||||
|
<h2 id="status-title">Status</h2>
|
||||||
|
</div>
|
||||||
|
<div data-slot="card-content">
|
||||||
|
<dl class="dashboard-status-list">
|
||||||
|
<div><dt>IPv4</dt><dd>{view.ipv4}</dd></div>
|
||||||
|
<div><dt>IPv6</dt><dd>{view.ipv6}</dd></div>
|
||||||
|
<div><dt>Updated</dt><dd>{view.updated}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _manual_updates_card(view: _ManagementPageView) -> SafeHtml:
|
||||||
|
return trusted(f"""
|
||||||
|
<section class="dashboard-card dashboard-card-compact" data-slot="card" aria-labelledby="manual-updates-title">
|
||||||
|
<div class="dashboard-card-header" data-slot="card-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Manual updates</p>
|
||||||
|
<h2 id="manual-updates-title">curl examples</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-slot="card-content">
|
||||||
|
<p class="section-copy">
|
||||||
|
FRITZ!Box fills IP placeholders automatically. Scripts can send an IP explicitly
|
||||||
|
or omit it and let KarlDNS use the request IP.
|
||||||
|
</p>
|
||||||
|
{_code_window(view.manual_commands, copy_label="manual update commands", class_name="manual-code")}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _management_history_section(history_rows: SafeHtml) -> SafeHtml:
|
||||||
|
return trusted(f"""
|
||||||
|
<section class="dashboard-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="dashboard-section-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">History</p>
|
||||||
|
<h2>Router updates</h2>
|
||||||
|
<p class="section-copy">Recent IP changes and failed updates for this hostname.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive dashboard-table-shell">
|
||||||
|
<table class="table table-hover dashboard-history-table mb-0">
|
||||||
|
<thead><tr><th>Time</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Count</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>{history_rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _management_delete_section(context: ManagementPageContext) -> SafeHtml:
|
||||||
|
return trusted(f"""
|
||||||
|
<section class="dashboard-section dashboard-danger-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="dashboard-danger-card" data-slot="card">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Delete</p>
|
||||||
|
<h2>Delete hostname</h2>
|
||||||
|
<p class="section-copy">
|
||||||
|
Remove this hostname and its DNS records. The update URL will stop working.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/m/{attr(context.management_slug)}/delete">
|
||||||
|
<input type="hidden" name="csrf" value="{attr(context.csrf_token)}">
|
||||||
|
<button class="btn btn-outline-danger rounded-pill" type="submit">Delete hostname</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_pending_management_page(service: DdnsService, context: PendingManagementPageContext) -> str:
|
||||||
|
raw_hostname = str(context.account["hostname"])
|
||||||
|
update_url = service.fritz_update_url(context.account_for_url)
|
||||||
|
dashboard_url = service.dashboard_url({"management_slug": context.management_slug})
|
||||||
hostname = escape(raw_hostname)
|
hostname = escape(raw_hostname)
|
||||||
message_html = _management_message(message)
|
message_html = _management_message(context.message)
|
||||||
cleanup_hours = settings.cleanup_pending_account_hours
|
cleanup_hours = context.settings.cleanup_pending_account_hours
|
||||||
cleanup_text = f"{cleanup_hours} hours" if cleanup_hours != 24 else "24 hours"
|
cleanup_text = f"{cleanup_hours} hours" if cleanup_hours != 24 else "24 hours"
|
||||||
manual_commands = _manual_update_commands(update_url, request_ip)
|
pending_lead = (
|
||||||
|
"Save these values, then register the hostname. Pending setups are cleaned up after " f"{cleanup_text}."
|
||||||
|
)
|
||||||
|
manual_commands = _manual_update_commands(update_url, context.request_ip)
|
||||||
|
hero = _pending_hero(hostname, pending_lead)
|
||||||
|
values_section = _pending_values_section(context, update_url, raw_hostname, dashboard_url)
|
||||||
|
router_section = _pending_router_section(hostname, update_url, manual_commands)
|
||||||
return _page(
|
return _page(
|
||||||
"KarlDNS setup",
|
"KarlDNS setup",
|
||||||
trusted(f"""
|
trusted(f"""
|
||||||
<main id="main">
|
<main id="main">
|
||||||
{_top_nav()}
|
{_top_nav()}
|
||||||
<section class="hero-band compact">
|
{hero}
|
||||||
<div class="container text-center d-grid justify-items-center gap-3">
|
|
||||||
<p class="eyebrow">Pending setup</p>
|
|
||||||
<h1>{hostname}</h1>
|
|
||||||
<p class="lead">Save these values, then register the hostname. Pending setups are cleaned up after {escape(cleanup_text)}.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{message_html}
|
{message_html}
|
||||||
<section class="section">
|
{values_section}
|
||||||
<div class="container narrow d-grid gap-4">
|
{router_section}
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Save</p>
|
|
||||||
<h2>Your values.</h2>
|
|
||||||
<p class="section-copy">Save the update URL and dashboard link before you register. After registration, the dashboard hides secrets by default.</p>
|
|
||||||
<p class="section-copy"><strong>Public:</strong> Domainnamen. <strong>Secret:</strong> Update-URL and private dashboard.</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-grid gap-2">
|
|
||||||
{_copy_row("Update-URL:", update_url, "Secret", mask_secret=False)}
|
|
||||||
{_copy_row("Domainnamen:", raw_hostname, "Public")}
|
|
||||||
{_copy_row("Private dashboard:", dashboard_url, "Secret", mask_secret=False)}
|
|
||||||
</div>
|
|
||||||
<form method="post" action="/m/{attr(management_slug)}/activate">
|
|
||||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
|
||||||
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="section section-compact">
|
|
||||||
<div class="container narrow d-grid gap-4">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">FRITZ!Box</p>
|
|
||||||
<h2>Router setup.</h2>
|
|
||||||
<p class="section-copy">Paste this exact URL into the FRITZ!Box <strong>Update-URL</strong> field. Use <strong>{hostname}</strong> as <strong>Domainnamen</strong>. No KarlDNS username or password is needed; if your router requires them, enter any placeholder.</p>
|
|
||||||
{_code_window(update_url, copy_label="FRITZ!Box update URL", class_name="mt-3")}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2>Manual updates.</h2>
|
|
||||||
<p class="section-copy">Scripts can send the IP explicitly, or omit it and let KarlDNS use the request IP. Visible examples use your public IP when KarlDNS or your browser can detect it.</p>
|
|
||||||
{_code_window(manual_commands, copy_label="manual update commands", class_name="manual-code mt-3")}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2>Use your own DNS name.</h2>
|
|
||||||
<p class="section-copy">Create a CNAME at your DNS provider that points to <strong>{hostname}</strong>. This is the same value as Domainnamen.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
"""),
|
"""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_hero(hostname: str, pending_lead: str) -> SafeHtml:
|
||||||
|
return trusted(f"""
|
||||||
|
<section class="hero-band compact">
|
||||||
|
<div class="container text-center d-grid justify-items-center gap-3">
|
||||||
|
<p class="eyebrow">Pending setup</p>
|
||||||
|
<h1>{hostname}</h1>
|
||||||
|
<p class="lead">{escape(pending_lead)}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_values_section(
|
||||||
|
context: PendingManagementPageContext,
|
||||||
|
update_url: str,
|
||||||
|
raw_hostname: str,
|
||||||
|
dashboard_url: str,
|
||||||
|
) -> SafeHtml:
|
||||||
|
return trusted(f"""
|
||||||
|
<section class="section">
|
||||||
|
<div class="container narrow d-grid gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Save</p>
|
||||||
|
<h2>Your values.</h2>
|
||||||
|
<p class="section-copy">
|
||||||
|
Save the update URL and dashboard link before you register. After registration,
|
||||||
|
the dashboard hides secrets by default.
|
||||||
|
</p>
|
||||||
|
<p class="section-copy">
|
||||||
|
<strong>Public:</strong> Domainnamen.
|
||||||
|
<strong>Secret:</strong> Update-URL and private dashboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
{_copy_row("Update-URL:", update_url, "Secret", mask_secret=False)}
|
||||||
|
{_copy_row("Domainnamen:", raw_hostname, "Public")}
|
||||||
|
{_copy_row("Private dashboard:", dashboard_url, "Secret", mask_secret=False)}
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/m/{attr(context.management_slug)}/activate">
|
||||||
|
<input type="hidden" name="csrf" value="{attr(context.csrf_token)}">
|
||||||
|
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_router_section(hostname: str, update_url: str, manual_commands: str) -> SafeHtml:
|
||||||
|
return trusted(f"""
|
||||||
|
<section class="section section-compact">
|
||||||
|
<div class="container narrow d-grid gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">FRITZ!Box</p>
|
||||||
|
<h2>Router setup.</h2>
|
||||||
|
<p class="section-copy">
|
||||||
|
Paste this exact URL into the FRITZ!Box <strong>Update-URL</strong> field.
|
||||||
|
Use <strong>{hostname}</strong> as <strong>Domainnamen</strong>.
|
||||||
|
No KarlDNS username or password is needed; if your router requires them,
|
||||||
|
enter any placeholder.
|
||||||
|
</p>
|
||||||
|
{_code_window(update_url, copy_label="FRITZ!Box update URL", class_name="mt-3")}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Manual updates.</h2>
|
||||||
|
<p class="section-copy">
|
||||||
|
Scripts can send the IP explicitly, or omit it and let KarlDNS use the request IP.
|
||||||
|
Visible examples use your public IP when KarlDNS or your browser can detect it.
|
||||||
|
</p>
|
||||||
|
{_code_window(manual_commands, copy_label="manual update commands", class_name="manual-code mt-3")}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Use your own DNS name.</h2>
|
||||||
|
<p class="section-copy">
|
||||||
|
Create a CNAME at your DNS provider that points to <strong>{hostname}</strong>.
|
||||||
|
This is the same value as Domainnamen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
def _management_message(message: str | None) -> SafeHtml:
|
def _management_message(message: str | None) -> SafeHtml:
|
||||||
if not message:
|
if not message:
|
||||||
return trusted("")
|
return trusted("")
|
||||||
return trusted(f"""
|
return trusted(f"""
|
||||||
<div class="alert alert-info mb-0 rounded-0 border-start-0 border-end-0" role="status">
|
<div class="alert alert-info ui-alert mb-0 rounded-0 border-start-0 border-end-0"
|
||||||
|
role="status" data-slot="alert">
|
||||||
<div class="container">{escape(message)}</div>
|
<div class="container">{escape(message)}</div>
|
||||||
</div>
|
</div>
|
||||||
""")
|
""")
|
||||||
@@ -248,10 +403,12 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
|
|||||||
remove_form = ""
|
remove_form = ""
|
||||||
if password_enabled:
|
if password_enabled:
|
||||||
remove_form = f"""
|
remove_form = f"""
|
||||||
<form class="dashboard-card dashboard-form-card" method="post" action="/m/{attr(management_slug)}/password/remove">
|
<form class="dashboard-card dashboard-form-card" data-slot="card" method="post"
|
||||||
|
action="/m/{attr(management_slug)}/password/remove">
|
||||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||||
<label class="form-label" for="dashboard-remove-current-password">Current password</label>
|
<label class="form-label" for="dashboard-remove-current-password">Current password</label>
|
||||||
<input class="form-control" id="dashboard-remove-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
|
<input class="form-control" id="dashboard-remove-current-password"
|
||||||
|
name="current_dashboard_password" type="password" autocomplete="current-password" required>
|
||||||
<button class="btn btn-outline-danger rounded-pill" type="submit">Remove password</button>
|
<button class="btn btn-outline-danger rounded-pill" type="submit">Remove password</button>
|
||||||
</form>
|
</form>
|
||||||
"""
|
"""
|
||||||
@@ -260,7 +417,8 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
|
|||||||
current_password_field = """
|
current_password_field = """
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" for="dashboard-current-password">Current password</label>
|
<label class="form-label" for="dashboard-current-password">Current password</label>
|
||||||
<input class="form-control" id="dashboard-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
|
<input class="form-control" id="dashboard-current-password"
|
||||||
|
name="current_dashboard_password" type="password" autocomplete="current-password" required>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
return trusted(f"""
|
return trusted(f"""
|
||||||
@@ -270,19 +428,26 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Dashboard password</p>
|
<p class="eyebrow">Dashboard password</p>
|
||||||
<h2>Password protection</h2>
|
<h2>Password protection</h2>
|
||||||
<p class="section-copy">Status: {escape(password_status)}. This password protects only this dashboard. Router updates still work.</p>
|
<p class="section-copy">
|
||||||
|
Status: {escape(password_status)}. This password protects only this dashboard.
|
||||||
|
Router updates still work.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-grid gap-3">
|
<div class="d-grid gap-3">
|
||||||
<form class="dashboard-card dashboard-form-card" method="post" action="/m/{attr(management_slug)}/password">
|
<form class="dashboard-card dashboard-form-card" data-slot="card" method="post"
|
||||||
|
action="/m/{attr(management_slug)}/password">
|
||||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||||
{current_password_field}
|
{current_password_field}
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" for="dashboard-password">{escape(password_action)}</label>
|
<label class="form-label" for="dashboard-password">{escape(password_action)}</label>
|
||||||
<input class="form-control" id="dashboard-password" name="dashboard_password" type="password" autocomplete="new-password" minlength="8" required>
|
<input class="form-control" id="dashboard-password" name="dashboard_password"
|
||||||
|
type="password" autocomplete="new-password" minlength="8" required>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" for="dashboard-password-confirm">Confirm password</label>
|
<label class="form-label" for="dashboard-password-confirm">Confirm password</label>
|
||||||
<input class="form-control" id="dashboard-password-confirm" name="dashboard_password_confirm" type="password" autocomplete="new-password" minlength="8" required>
|
<input class="form-control" id="dashboard-password-confirm"
|
||||||
|
name="dashboard_password_confirm" type="password" autocomplete="new-password"
|
||||||
|
minlength="8" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2 flex-wrap">
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
<button class="btn btn-primary rounded-pill" type="submit">{escape(password_action)}</button>
|
<button class="btn btn-primary rounded-pill" type="submit">{escape(password_action)}</button>
|
||||||
@@ -305,7 +470,9 @@ def _render_management_unlock_page(
|
|||||||
hostname = escape(account["hostname"])
|
hostname = escape(account["hostname"])
|
||||||
message_html = ""
|
message_html = ""
|
||||||
if message:
|
if message:
|
||||||
message_html = f'<div class="alert alert-warning" role="alert">{escape(message)}</div>'
|
message_html = (
|
||||||
|
f'<div class="alert alert-warning ui-alert" role="alert" data-slot="alert">{escape(message)}</div>'
|
||||||
|
)
|
||||||
return _page(
|
return _page(
|
||||||
"Unlock KarlDNS management",
|
"Unlock KarlDNS management",
|
||||||
trusted(f"""
|
trusted(f"""
|
||||||
@@ -321,11 +488,13 @@ def _render_management_unlock_page(
|
|||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container narrow">
|
<div class="container narrow">
|
||||||
{message_html}
|
{message_html}
|
||||||
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/unlock">
|
<form class="card card-body gap-3" data-slot="card" method="post"
|
||||||
|
action="/m/{attr(management_slug)}/unlock">
|
||||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" for="dashboard-unlock-password">Dashboard password</label>
|
<label class="form-label" for="dashboard-unlock-password">Dashboard password</label>
|
||||||
<input class="form-control" id="dashboard-unlock-password" name="dashboard_password" type="password" autocomplete="current-password" required autofocus>
|
<input class="form-control" id="dashboard-unlock-password" name="dashboard_password"
|
||||||
|
type="password" autocomplete="current-password" required autofocus>
|
||||||
</div>
|
</div>
|
||||||
<label class="form-check d-flex align-items-center gap-2">
|
<label class="form-check d-flex align-items-center gap-2">
|
||||||
<input class="form-check-input" type="checkbox" name="stay_unlocked" value="1">
|
<input class="form-check-input" type="checkbox" name="stay_unlocked" value="1">
|
||||||
@@ -341,15 +510,39 @@ def _render_management_unlock_page(
|
|||||||
|
|
||||||
|
|
||||||
def _user_event_row(event: Mapping[str, object]) -> str:
|
def _user_event_row(event: Mapping[str, object]) -> str:
|
||||||
created = escape(event.get("created_at") or "-")
|
row = _user_event_row_view(event)
|
||||||
|
return (
|
||||||
|
f'<tr><td>{row.created}</td><td><span class="status-badge {attr(row.badge_class)}">{row.status}</span></td>'
|
||||||
|
f"<td>{row.ipv4}</td><td>{row.ipv6}</td><td>{row.count}</td><td>{row.detail}</td></tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _UserEventRow:
|
||||||
|
created: str
|
||||||
|
status: str
|
||||||
|
badge_class: str
|
||||||
|
ipv4: str
|
||||||
|
ipv6: str
|
||||||
|
count: str
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
|
def _user_event_row_view(event: Mapping[str, object]) -> _UserEventRow:
|
||||||
raw_status = str(event.get("status") or "")
|
raw_status = str(event.get("status") or "")
|
||||||
status = escape(_event_label(raw_status))
|
return _UserEventRow(
|
||||||
badge_class = attr(_event_badge_class(raw_status))
|
created=escape(_event_text(event, "created_at")),
|
||||||
ipv4 = escape(event.get("ipv4") or "-")
|
status=escape(_event_label(raw_status)),
|
||||||
ipv6 = escape(event.get("ipv6") or "-")
|
badge_class=_event_badge_class(raw_status),
|
||||||
count = escape(event.get("count") or 1)
|
ipv4=escape(_event_text(event, "ipv4")),
|
||||||
detail = escape(event.get("detail") or "-")
|
ipv6=escape(_event_text(event, "ipv6")),
|
||||||
return f'<tr><td>{created}</td><td><span class="status-badge {badge_class}">{status}</span></td><td>{ipv4}</td><td>{ipv6}</td><td>{count}</td><td>{detail}</td></tr>'
|
count=escape(event.get("count") or 1),
|
||||||
|
detail=escape(_event_text(event, "detail")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_text(event: Mapping[str, object], key: str) -> str:
|
||||||
|
return str(event.get(key) or "-")
|
||||||
|
|
||||||
|
|
||||||
def _event_badge_class(status: str) -> str:
|
def _event_badge_class(status: str) -> str:
|
||||||
|
|||||||
+223
-145
@@ -1,12 +1,25 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from ..settings import DdnsSettings
|
from ..settings import DdnsSettings
|
||||||
from .components import _code_window, _example_update_ips, _message_band
|
from .components import _code_window, _example_update_ips, _message_band
|
||||||
from .html_utils import escape, trusted
|
from .html_utils import trusted
|
||||||
from .layout import _page, _top_nav
|
from .layout import PageOptions, _page, _top_nav
|
||||||
|
from .sections.public_home import _manual_updates_section, _public_home_hero, _setup_flow_section
|
||||||
from .templates_public_sections import _linux_installer_section
|
from .templates_public_sections import _linux_installer_section
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _ApiDocsExamples:
|
||||||
|
create: str
|
||||||
|
register: str
|
||||||
|
update: str
|
||||||
|
status: str
|
||||||
|
rotate: str
|
||||||
|
schema: str
|
||||||
|
|
||||||
|
|
||||||
def _render_public_home(
|
def _render_public_home(
|
||||||
settings: DdnsSettings,
|
settings: DdnsSettings,
|
||||||
message: str | None,
|
message: str | None,
|
||||||
@@ -14,8 +27,7 @@ def _render_public_home(
|
|||||||
) -> str:
|
) -> str:
|
||||||
base_url = settings.public_base_url.rstrip("/")
|
base_url = settings.public_base_url.rstrip("/")
|
||||||
hostname_suffix = settings.hostname_suffix or "your KarlDNS suffix"
|
hostname_suffix = settings.hostname_suffix or "your KarlDNS suffix"
|
||||||
suffix = escape(hostname_suffix)
|
demo_update_url = f"{base_url}/api/v1/router/update/<secret>?myip=<ipaddr>"
|
||||||
demo_update_url = escape(f"{base_url}/api/v1/router/update/<secret>?myip=<ipaddr>")
|
|
||||||
example_ipv4, _ = _example_update_ips(request_ip)
|
example_ipv4, _ = _example_update_ips(request_ip)
|
||||||
create_demo_command = f"curl -sS '{base_url}/api/v1/router/update/<secret>?myip={example_ipv4}'"
|
create_demo_command = f"curl -sS '{base_url}/api/v1/router/update/<secret>?myip={example_ipv4}'"
|
||||||
create_demo = f"{create_demo_command}\ngood {example_ipv4}"
|
create_demo = f"{create_demo_command}\ngood {example_ipv4}"
|
||||||
@@ -28,81 +40,55 @@ def _render_public_home(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
message_html = _message_band(message) if message else ""
|
message_html = _message_band(message) if message else ""
|
||||||
description = "KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, generated KarlDNS hostnames, Linux cron jobs, and home networks."
|
description = (
|
||||||
|
"KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, "
|
||||||
|
"generated KarlDNS hostnames, Linux cron jobs, and home networks."
|
||||||
|
)
|
||||||
return _page(
|
return _page(
|
||||||
"KarlDNS",
|
"KarlDNS",
|
||||||
trusted(f"""
|
trusted(f"""
|
||||||
<main id="main">
|
<main id="main">
|
||||||
{_top_nav()}
|
{_top_nav()}
|
||||||
<section class="hero-band">
|
{_public_home_hero(suffix=hostname_suffix, demo_update_url=demo_update_url)}
|
||||||
<div class="container text-center d-grid justify-items-center gap-3">
|
|
||||||
<img class="hero-logo" src="/logo.webp?v=3" alt="" width="80" height="80" decoding="async" fetchpriority="high">
|
|
||||||
<h1>Dynamic DNS for home routers.</h1>
|
|
||||||
<div class="install-pill mx-auto">
|
|
||||||
<code>{demo_update_url}</code>
|
|
||||||
</div>
|
|
||||||
<p class="lead">KarlDNS gives you a generated DynDNS hostname under {suffix}. Add the private update URL to your router, or use it from a script.</p>
|
|
||||||
<div class="d-flex gap-2 flex-wrap justify-content-center mt-2">
|
|
||||||
<form method="post" action="/" class="hero-action-form" aria-label="Create a generated DynDNS hostname">
|
|
||||||
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
|
|
||||||
</form>
|
|
||||||
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">Manual updates</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{message_html}
|
{message_html}
|
||||||
<section class="section section-compact">
|
{_setup_flow_section(create_demo=create_demo, create_demo_command=create_demo_command)}
|
||||||
<div class="container narrow text-center">
|
{_manual_updates_section(manual_demo=manual_demo)}
|
||||||
<p class="eyebrow">How it works</p>
|
|
||||||
<h2>No account needed.</h2>
|
|
||||||
<p class="section-copy mx-auto">Create a hostname, save the private values, register it, done.</p>
|
|
||||||
<ol class="flow-steps" aria-label="KarlDNS setup flow">
|
|
||||||
<li><strong>Create</strong><span>Random hostname and private links.</span></li>
|
|
||||||
<li><strong>Save</strong><span>Copy the secrets while they are shown.</span></li>
|
|
||||||
<li><strong>Register</strong><span>Activate the hostname when you are ready.</span></li>
|
|
||||||
<li><strong>Update</strong><span>Router, cron, or CNAME can use it.</span></li>
|
|
||||||
</ol>
|
|
||||||
{_code_window(create_demo, copy_label="update example", class_name="mt-4", copy_value=create_demo_command)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="section" id="manual-updates">
|
|
||||||
<div class="container narrow text-center">
|
|
||||||
<h2>Update from a router or script.</h2>
|
|
||||||
<p class="lead mx-auto mt-3">Works with FRITZ!Box, curl, cron, Home Assistant, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, and custom tools.</p>
|
|
||||||
{_code_window(manual_demo, copy_label="manual update examples", class_name="manual-code text-start mt-4")}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{_linux_installer_section(base_url)}
|
{_linux_installer_section(base_url)}
|
||||||
</main>
|
</main>
|
||||||
"""),
|
"""),
|
||||||
description=description,
|
PageOptions(
|
||||||
preload_logo=True,
|
description=description,
|
||||||
canonical_url=f"{base_url}/",
|
preload_logo=True,
|
||||||
image_url=f"{base_url}/social-preview.png",
|
canonical_url=f"{base_url}/",
|
||||||
image_alt="KarlDNS landing page with private Dynamic DNS update URL",
|
image_url=f"{base_url}/social-preview.png",
|
||||||
structured_data={
|
image_alt="KarlDNS landing page with private Dynamic DNS update URL",
|
||||||
"@context": "https://schema.org",
|
structured_data={
|
||||||
"@type": "SoftwareApplication",
|
"@context": "https://schema.org",
|
||||||
"name": "KarlDNS",
|
"@type": "SoftwareApplication",
|
||||||
"applicationCategory": "NetworkingApplication",
|
"name": "KarlDNS",
|
||||||
"operatingSystem": "Linux",
|
"applicationCategory": "NetworkingApplication",
|
||||||
"url": f"{base_url}/",
|
"operatingSystem": "Linux",
|
||||||
"description": description,
|
"url": f"{base_url}/",
|
||||||
"image": f"{base_url}/social-preview.png",
|
"description": description,
|
||||||
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "EUR"},
|
"image": f"{base_url}/social-preview.png",
|
||||||
"featureList": [
|
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "EUR"},
|
||||||
"Dynamic DNS for FRITZ!Box routers",
|
"featureList": [
|
||||||
"Accountless private update URLs",
|
"Dynamic DNS for FRITZ!Box routers",
|
||||||
"CNAME target for external DNS names",
|
"Accountless private update URLs",
|
||||||
"Linux cron updater",
|
"CNAME target for external DNS names",
|
||||||
],
|
"Linux cron updater",
|
||||||
},
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_privacy_page(settings: DdnsSettings) -> str:
|
def _render_privacy_page(settings: DdnsSettings) -> str:
|
||||||
base_url = settings.public_base_url.rstrip("/")
|
base_url = settings.public_base_url.rstrip("/")
|
||||||
description = "Privacy and legal information for KarlDNS, including stored hostnames, update links, IP history, and cleanup behavior."
|
description = (
|
||||||
|
"Privacy and legal information for KarlDNS, including stored hostnames, update links, "
|
||||||
|
"IP history, and cleanup behavior."
|
||||||
|
)
|
||||||
return _page(
|
return _page(
|
||||||
"Legal and Privacy",
|
"Legal and Privacy",
|
||||||
trusted(f"""
|
trusted(f"""
|
||||||
@@ -119,113 +105,205 @@ def _render_privacy_page(settings: DdnsSettings) -> str:
|
|||||||
<div class="container narrow d-grid gap-4">
|
<div class="container narrow d-grid gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2>Data processed</h2>
|
<h2>Data processed</h2>
|
||||||
<p class="section-copy">KarlDNS stores hostnames, protected dashboard tokens, private update links, recent IP history, timestamps, and service events.</p>
|
<p class="section-copy">
|
||||||
|
KarlDNS stores hostnames, protected dashboard tokens, private update links,
|
||||||
|
recent IP history, timestamps, and service events.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2>Secrets</h2>
|
<h2>Secrets</h2>
|
||||||
<p class="section-copy">Dashboard links, registration URLs, and update URLs are secrets. Anyone with them may update or manage the hostname. Dashboard lookup values are not stored as readable text.</p>
|
<p class="section-copy">
|
||||||
|
Dashboard links, registration URLs, and update URLs are secrets.
|
||||||
|
Anyone with them may update or manage the hostname.
|
||||||
|
Dashboard lookup values are not stored as readable text.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2>Logs and retention</h2>
|
<h2>Logs and retention</h2>
|
||||||
<p class="section-copy">KarlDNS keeps recent update events for troubleshooting. Cleanup removes unused hostnames. Server logs may also include IP addresses and request details.</p>
|
<p class="section-copy">
|
||||||
|
KarlDNS keeps recent update events for troubleshooting. Cleanup removes unused hostnames.
|
||||||
|
Server logs may also include IP addresses and request details.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2>Contact</h2>
|
<h2>Contact</h2>
|
||||||
<p class="section-copy">For the public KarlDNS instance, contact Karl through <a href="https://karl.fail" rel="noopener">karl.fail</a> or <a href="https://karlcom.de" rel="noopener">karlcom.de</a>.</p>
|
<p class="section-copy">
|
||||||
|
For the public KarlDNS instance, contact Karl through
|
||||||
|
<a href="https://karl.fail" rel="noopener">karl.fail</a> or
|
||||||
|
<a href="https://karlcom.de" rel="noopener">karlcom.de</a>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
"""),
|
"""),
|
||||||
description=description,
|
PageOptions(
|
||||||
canonical_url=f"{base_url}/privacy",
|
description=description,
|
||||||
image_url=f"{base_url}/social-preview.png",
|
canonical_url=f"{base_url}/privacy",
|
||||||
image_alt="KarlDNS legal and privacy information",
|
image_url=f"{base_url}/social-preview.png",
|
||||||
structured_data={
|
image_alt="KarlDNS legal and privacy information",
|
||||||
"@context": "https://schema.org",
|
structured_data={
|
||||||
"@type": "WebPage",
|
"@context": "https://schema.org",
|
||||||
"name": "KarlDNS Legal and Privacy",
|
"@type": "WebPage",
|
||||||
"url": f"{base_url}/privacy",
|
"name": "KarlDNS Legal and Privacy",
|
||||||
"description": description,
|
"url": f"{base_url}/privacy",
|
||||||
"isPartOf": {
|
"description": description,
|
||||||
"@type": "WebSite",
|
"isPartOf": {
|
||||||
"name": "KarlDNS",
|
"@type": "WebSite",
|
||||||
"url": f"{base_url}/",
|
"name": "KarlDNS",
|
||||||
|
"url": f"{base_url}/",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None) -> str:
|
def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None) -> str:
|
||||||
base_url = settings.public_base_url.rstrip("/")
|
base_url = settings.public_base_url.rstrip("/")
|
||||||
example_ipv4, _ = _example_update_ips(request_ip)
|
example_ipv4, _ = _example_update_ips(request_ip)
|
||||||
create_example = f"curl -sS -X POST {base_url}/api/v1/hostnames"
|
examples = _api_docs_examples(base_url, example_ipv4)
|
||||||
register_example = (
|
description = (
|
||||||
f"curl -sS -X POST '{base_url}/api/v1/router/register/<karl_dashboard_secret>?myip={example_ipv4}'"
|
"Small KarlDNS API guide for creating, activating, updating, reading, and deleting "
|
||||||
|
"generated Dynamic DNS hostnames."
|
||||||
)
|
)
|
||||||
update_example = f"curl -sS '{base_url}/api/v1/router/update/<karl_update_secret>?myip={example_ipv4}'"
|
|
||||||
status_example = f"curl -sS '{base_url}/api/v1/hostnames/<karl_dashboard_secret>?history_limit=10'"
|
|
||||||
rotate_example = f"curl -sS -X POST {base_url}/api/v1/hostnames/<karl_dashboard_secret>/rotate-update-url"
|
|
||||||
schema_example = f"curl -sS {base_url}/openapi.json"
|
|
||||||
description = "Small KarlDNS API guide for creating, activating, updating, reading, and deleting generated Dynamic DNS hostnames."
|
|
||||||
return _page(
|
return _page(
|
||||||
"KarlDNS API",
|
"KarlDNS API",
|
||||||
trusted(f"""
|
trusted(f"""
|
||||||
<main id="main">
|
<main id="main">
|
||||||
{_top_nav()}
|
{_top_nav()}
|
||||||
<section class="hero-band compact">
|
{_api_docs_hero()}
|
||||||
<div class="container text-center d-grid justify-items-center gap-3">
|
{_api_docs_content(examples)}
|
||||||
<p class="eyebrow">API</p>
|
|
||||||
<h1>Small API surface.</h1>
|
|
||||||
<p class="lead">Create a hostname, register it, update it, and read its status.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="section section-compact">
|
|
||||||
<div class="container narrow d-grid gap-4">
|
|
||||||
<div class="api-endpoints">
|
|
||||||
<h2>Endpoints</h2>
|
|
||||||
<ol class="simple-steps text-start">
|
|
||||||
<li><strong>POST /api/v1/hostnames.</strong> Create a pending hostname and private setup values. The response includes <code>registration_url</code>.</li>
|
|
||||||
<li><strong>GET or POST /api/v1/router/register/<karl_secret>.</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/<karl_secret>.</strong> Router-friendly update endpoint. Supports <code>myip</code>, <code>ipaddr</code>, <code>myipv6</code>, and <code>ip6addr</code>.</li>
|
|
||||||
<li><strong>GET /api/v1/hostnames/<karl_secret>.</strong> Read hostname status and recent history.</li>
|
|
||||||
<li><strong>POST /api/v1/hostnames/<karl_secret>/rotate-update-url.</strong> Replace the private router update URL.</li>
|
|
||||||
<li><strong>DELETE /api/v1/hostnames/<karl_secret>.</strong> Delete hostname and DNS records.</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2>Create</h2>
|
|
||||||
{_code_window(create_example, copy_label="create command", class_name="mt-3")}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2>Register</h2>
|
|
||||||
{_code_window(register_example, copy_label="register command", class_name="mt-3")}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2>Update</h2>
|
|
||||||
{_code_window(update_example, copy_label="update command", class_name="mt-3")}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2>Status</h2>
|
|
||||||
<p class="section-copy">Use the private dashboard secret to read current DNS state and recent updates for only that hostname.</p>
|
|
||||||
{_code_window(status_example, copy_label="status command", class_name="mt-3")}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2>Rotate update URL</h2>
|
|
||||||
<p class="section-copy">Use the private dashboard secret to replace the router update URL. The old update URL stops working immediately.</p>
|
|
||||||
{_code_window(rotate_example, copy_label="rotate command", class_name="mt-3")}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2>Schema</h2>
|
|
||||||
<p class="section-copy">Machine-readable OpenAPI is available for the public hostname and router endpoints.</p>
|
|
||||||
{_code_window(schema_example, copy_label="schema command", class_name="mt-3")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
"""),
|
"""),
|
||||||
description=description,
|
PageOptions(
|
||||||
canonical_url=f"{base_url}/docs",
|
description=description,
|
||||||
image_url=f"{base_url}/social-preview.png",
|
canonical_url=f"{base_url}/docs",
|
||||||
image_alt="KarlDNS API documentation",
|
image_url=f"{base_url}/social-preview.png",
|
||||||
|
image_alt="KarlDNS API documentation",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _api_docs_examples(base_url: str, example_ipv4: str) -> _ApiDocsExamples:
|
||||||
|
return _ApiDocsExamples(
|
||||||
|
create=f"curl -sS -X POST {base_url}/api/v1/hostnames",
|
||||||
|
register=f"curl -sS -X POST '{base_url}/api/v1/router/register/<karl_dashboard_secret>?myip={example_ipv4}'",
|
||||||
|
update=f"curl -sS '{base_url}/api/v1/router/update/<karl_update_secret>?myip={example_ipv4}'",
|
||||||
|
status=f"curl -sS '{base_url}/api/v1/hostnames/<karl_dashboard_secret>?history_limit=10'",
|
||||||
|
rotate=f"curl -sS -X POST {base_url}/api/v1/hostnames/<karl_dashboard_secret>/rotate-update-url",
|
||||||
|
schema=f"curl -sS {base_url}/openapi.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _api_docs_hero() -> str:
|
||||||
|
return """
|
||||||
|
<section class="hero-band compact">
|
||||||
|
<div class="container text-center d-grid justify-items-center gap-3">
|
||||||
|
<p class="eyebrow">API</p>
|
||||||
|
<h1>Small API surface.</h1>
|
||||||
|
<p class="lead">Create a hostname, register it, update it, and read its status.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _api_docs_content(examples: _ApiDocsExamples) -> str:
|
||||||
|
return f"""
|
||||||
|
<section class="section section-compact">
|
||||||
|
<div class="container narrow d-grid gap-4">
|
||||||
|
{_api_endpoints_overview()}
|
||||||
|
{_api_example_block("Create", examples.create, "create command")}
|
||||||
|
{_api_example_block("Register", examples.register, "register command")}
|
||||||
|
{_api_example_block("Update", examples.update, "update command")}
|
||||||
|
{_api_status_block(examples.status)}
|
||||||
|
{_api_rotate_block(examples.rotate)}
|
||||||
|
{_api_schema_block(examples.schema)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _api_endpoints_overview() -> str:
|
||||||
|
return """
|
||||||
|
<div class="api-endpoints">
|
||||||
|
<h2>Endpoints</h2>
|
||||||
|
<ol class="simple-steps text-start">
|
||||||
|
<li>
|
||||||
|
<strong>POST /api/v1/hostnames.</strong>
|
||||||
|
Create a pending hostname and private setup values. The response includes
|
||||||
|
<code>registration_url</code>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>GET or POST /api/v1/router/register/<karl_secret>.</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/<karl_secret>.</strong>
|
||||||
|
Router-friendly update endpoint. Supports <code>myip</code>, <code>ipaddr</code>,
|
||||||
|
<code>myipv6</code>, and <code>ip6addr</code>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>GET /api/v1/hostnames/<karl_secret>.</strong>
|
||||||
|
Read hostname status and recent history.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>POST /api/v1/hostnames/<karl_secret>/rotate-update-url.</strong>
|
||||||
|
Replace the private router update URL.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>DELETE /api/v1/hostnames/<karl_secret>.</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>
|
||||||
|
"""
|
||||||
@@ -14,7 +14,11 @@ def _linux_installer_section(base_url: str) -> SafeHtml:
|
|||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
<p class="eyebrow">Linux cron updater</p>
|
<p class="eyebrow">Linux cron updater</p>
|
||||||
<h2>Linux updater.</h2>
|
<h2>Linux updater.</h2>
|
||||||
<p class="section-copy mx-auto">Run this on a Linux box that is always on. It creates and registers a hostname, installs the updater, schedules cron, and prints your private dashboard link. Works with Debian, Ubuntu, Fedora, Arch, Alpine, and Proxmox hosts.</p>
|
<p class="section-copy mx-auto">
|
||||||
|
Run this on a Linux box that is always on. It creates and registers a hostname,
|
||||||
|
installs the updater, schedules cron, and prints your private dashboard link.
|
||||||
|
Works with Debian, Ubuntu, Fedora, Arch, Alpine, and Proxmox hosts.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="installer-commands">
|
<div class="installer-commands">
|
||||||
<div class="installer-command">
|
<div class="installer-command">
|
||||||
|
|||||||
+6
-2
@@ -55,8 +55,12 @@ line-length = 120
|
|||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "UP", "B"]
|
select = ["E", "F", "I", "UP", "B", "ANN"]
|
||||||
ignore = ["E501"]
|
ignore = ["ANN401"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"scripts/build-bootstrap-subset.py" = ["E501"]
|
||||||
|
"tests/**/*.py" = ["ANN"]
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 120
|
line-length = 120
|
||||||
|
|||||||
+4
-1
@@ -16,7 +16,7 @@ def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) ->
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers["cache-control"] == "no-store"
|
assert response.headers["cache-control"] == "no-store"
|
||||||
assert '<link href="/admin.css?v=5" rel="stylesheet">' in response.text
|
assert '<link href="/admin.css?v=5" rel="stylesheet">' in response.text
|
||||||
assert '<script src="/admin-tables.js?v=2" defer></script>' in response.text
|
assert '<script src="/admin-tables.js?v=3" defer></script>' in response.text
|
||||||
assert "Operator console" in response.text
|
assert "Operator console" in response.text
|
||||||
assert "Generate credentials" not in response.text
|
assert "Generate credentials" not in response.text
|
||||||
assert 'action="/admin/accounts"' not in response.text
|
assert 'action="/admin/accounts"' not in response.text
|
||||||
@@ -24,6 +24,9 @@ def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) ->
|
|||||||
assert response.text.count("data-table-search") == 2
|
assert response.text.count("data-table-search") == 2
|
||||||
assert response.text.count("data-table-page-size") == 2
|
assert response.text.count("data-table-page-size") == 2
|
||||||
assert response.text.count("data-table-pagination") == 2
|
assert response.text.count("data-table-pagination") == 2
|
||||||
|
assert 'placeholder="Filter visible hostnames"' in response.text
|
||||||
|
assert 'placeholder="Filter visible update events"' in response.text
|
||||||
|
assert 'placeholder="Search hostnames"' not in response.text
|
||||||
assert "admin-table-scroll" in response.text
|
assert "admin-table-scroll" in response.text
|
||||||
assert "Cleanup" in response.text
|
assert "Cleanup" in response.text
|
||||||
assert "Run now" in response.text
|
assert "Run now" in response.text
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from karldns import DdnsStore
|
||||||
|
from karldns.routers.admin import _admin_cleanup_notice, _admin_table_offsets
|
||||||
|
from karldns.web.templates_admin import (
|
||||||
|
_account_rows,
|
||||||
|
_admin_page_context,
|
||||||
|
_cleanup_accounts_removed,
|
||||||
|
_cleanup_last_run,
|
||||||
|
_cleanup_summary,
|
||||||
|
_cleanup_task,
|
||||||
|
_event_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_page_context_collects_counts_and_offset_rows(tmp_path: Path) -> None:
|
||||||
|
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
||||||
|
first = store.create_account("first.example.net", "router")
|
||||||
|
second = store.create_account("second.example.net", "router")
|
||||||
|
store.log_update_event(first["hostname"], None, None, "first", "first detail", "198.51.100.1")
|
||||||
|
store.log_update_event(second["hostname"], None, None, "second", "second detail", "198.51.100.2")
|
||||||
|
|
||||||
|
context = _admin_page_context(
|
||||||
|
store,
|
||||||
|
table_offsets={"accounts": 1, "update_events": 1},
|
||||||
|
table_limit=1,
|
||||||
|
notice_html=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert context.accounts[0]["hostname"] == "second.example.net"
|
||||||
|
assert context.update_events[0]["hostname"] == "first.example.net"
|
||||||
|
assert context.table_counts["accounts"] == 2
|
||||||
|
assert context.table_counts["update_events"] == 2
|
||||||
|
assert context.table_limit == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_rows_render_empty_states_when_tables_have_no_rows() -> None:
|
||||||
|
assert "No hostnames yet." in _account_rows([], csrf="csrf", admin_path="/admin")
|
||||||
|
assert "No update events yet." in _event_rows([])
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_table_offsets_groups_query_values() -> None:
|
||||||
|
assert _admin_table_offsets(accounts_offset=1, cleanup_runs_offset=2, update_events_offset=3) == {
|
||||||
|
"accounts": 1,
|
||||||
|
"cleanup_runs": 2,
|
||||||
|
"update_events": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cleanup_notice_is_empty_without_done_flag() -> None:
|
||||||
|
assert _admin_cleanup_notice(cleanup_done=False, cleanup_unused=1, cleanup_events=2) is None
|
||||||
|
assert "Removed 1 hostnames and 2 old events" in _admin_cleanup_notice(
|
||||||
|
cleanup_done=True,
|
||||||
|
cleanup_unused=1,
|
||||||
|
cleanup_events=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_task_prefers_named_cleanup_task() -> None:
|
||||||
|
task = _cleanup_task(
|
||||||
|
[
|
||||||
|
{"name": "other", "next_run_at": "wrong"},
|
||||||
|
{"name": "cleanup", "next_run_at": "right"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert task["next_run_at"] == "right"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_summary_uses_task_and_last_run_fallback() -> None:
|
||||||
|
summary = _cleanup_summary(
|
||||||
|
cleanup_runs=[{"created_at": "2026-01-02T03:04:05+00:00", "unused_accounts_deleted": 3}],
|
||||||
|
scheduled_tasks=[{"name": "cleanup", "interval_seconds": 3600}],
|
||||||
|
fallback_interval_seconds=7200,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary.last_run == "2026-01-02T03:04:05+00:00"
|
||||||
|
assert summary.next_run is None
|
||||||
|
assert summary.status == "pending"
|
||||||
|
assert summary.interval_seconds == 3600
|
||||||
|
assert summary.accounts_removed == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_summary_helpers_prefer_task_and_default_missing_cleanup_values() -> None:
|
||||||
|
task = {"last_finished_at": "2026-01-03T00:00:00+00:00"}
|
||||||
|
cleanup = {"created_at": "2026-01-02T00:00:00+00:00"}
|
||||||
|
|
||||||
|
assert _cleanup_last_run(task, cleanup) == "2026-01-03T00:00:00+00:00"
|
||||||
|
assert _cleanup_last_run({}, cleanup) == "2026-01-02T00:00:00+00:00"
|
||||||
|
assert _cleanup_accounts_removed({}) == 0
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from karldns.web.templates_admin_tables import _admin_event_row_view
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_event_row_normalizes_missing_values() -> None:
|
||||||
|
row = _admin_event_row_view({"created_at": "", "status": "updated"})
|
||||||
|
|
||||||
|
assert row.created_raw == "-"
|
||||||
|
assert row.hostname_raw == "-"
|
||||||
|
assert row.detail_raw == "-"
|
||||||
|
assert row.count == "1"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from karldns.web.components import _code_copy_line, _code_copy_value, _copy_row, _CopyRowParts
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_row_parts_derive_secret_state_and_badge() -> None:
|
||||||
|
parts = _CopyRowParts.from_values("Update-URL:", "https://example.test/update", "Secret", mask_secret=True)
|
||||||
|
|
||||||
|
assert parts.is_secret is True
|
||||||
|
assert parts.should_mask is True
|
||||||
|
assert parts.row_class == "copy-field copy-field-secret"
|
||||||
|
assert "ui-badge-secret" in parts.sensitivity_badge
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_row_masks_secret_values_by_default() -> None:
|
||||||
|
html = _copy_row("Update-URL:", "https://example.test/update", "Secret")
|
||||||
|
|
||||||
|
assert 'class="copy-field copy-field-secret"' in html
|
||||||
|
assert 'type="password"' in html
|
||||||
|
assert "data-secret-toggle" in html
|
||||||
|
assert 'data-copy="https://example.test/update"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_row_can_render_unmasked_secret_values() -> None:
|
||||||
|
html = _copy_row("Update-URL:", "https://example.test/update", "Secret", mask_secret=False)
|
||||||
|
|
||||||
|
assert 'class="copy-field copy-field-secret"' in html
|
||||||
|
assert 'type="password"' not in html
|
||||||
|
assert '<code class="copy-pill-value copy-value">https://example.test/update</code>' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_copy_value_prefers_prompt_lines_when_present() -> None:
|
||||||
|
commands = "intro\n$ first command\noutput\n$ second command"
|
||||||
|
|
||||||
|
assert _code_copy_value(commands) == "first command\nsecond command"
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_copy_line_strips_shell_prompt() -> None:
|
||||||
|
assert _code_copy_line("$ curl example") == "curl example"
|
||||||
|
assert _code_copy_line("plain output") == "plain output"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from karldns.services.ddns import (
|
||||||
|
INITIAL_PLACEHOLDER_IPV4,
|
||||||
|
BadIpError,
|
||||||
|
UpdateIpParams,
|
||||||
|
_initial_dns_plan,
|
||||||
|
_required_ip_param,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_ip_params_expose_initial_plan_arguments() -> None:
|
||||||
|
params = UpdateIpParams(myip="203.0.113.9", ipaddr=None, myipv6="2001:db8::9", ip6addr=None)
|
||||||
|
|
||||||
|
assert params.initial_plan_args == ("203.0.113.9", None, "2001:db8::9", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_required_ip_param_rejects_invalid_or_wrong_version_values() -> None:
|
||||||
|
assert _required_ip_param("myip", "203.0.113.77", version=4) == "203.0.113.77"
|
||||||
|
|
||||||
|
with pytest.raises(BadIpError, match="valid IPv4"):
|
||||||
|
_required_ip_param("myip", "not-an-ip", version=4)
|
||||||
|
|
||||||
|
with pytest.raises(BadIpError, match="IPv4"):
|
||||||
|
_required_ip_param("myip", "2001:db8::1", version=4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_dns_plan_uses_placeholder_without_supplied_or_public_source_ip() -> None:
|
||||||
|
plan = _initial_dns_plan(
|
||||||
|
source_ip="172.18.0.5",
|
||||||
|
myip=None,
|
||||||
|
ipaddr=None,
|
||||||
|
myipv6=None,
|
||||||
|
ip6addr=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert plan.ipv4 == INITIAL_PLACEHOLDER_IPV4
|
||||||
|
assert plan.ipv6 is None
|
||||||
|
assert plan.update_ipv4 is True
|
||||||
|
assert plan.update_ipv6 is False
|
||||||
|
assert plan.detail == "initial placeholder dns registered"
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_dns_plan_uses_supplied_ip_values() -> None:
|
||||||
|
plan = _initial_dns_plan(
|
||||||
|
source_ip="172.18.0.5",
|
||||||
|
myip="203.0.113.77",
|
||||||
|
ipaddr=None,
|
||||||
|
myipv6=None,
|
||||||
|
ip6addr=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert plan.ipv4 == "203.0.113.77"
|
||||||
|
assert plan.ipv6 is None
|
||||||
|
assert plan.update_ipv4 is True
|
||||||
|
assert plan.update_ipv6 is False
|
||||||
|
assert plan.detail == "initial dns registered"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import base64
|
||||||
|
|
||||||
|
from karldns.dependencies import _basic_auth_password
|
||||||
|
|
||||||
|
|
||||||
|
def _basic_header(username: str, password: str) -> str:
|
||||||
|
token = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||||
|
return f"Basic {token}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_auth_password_extracts_only_basic_passwords() -> None:
|
||||||
|
assert _basic_auth_password(_basic_header("admin", "secret")) == "secret"
|
||||||
|
assert _basic_auth_password("Bearer token") is None
|
||||||
|
assert _basic_auth_password(None) is None
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from karldns.web.html_utils import trusted
|
||||||
|
from karldns.web.layout import PageOptions, _page, _social_meta_tags, _structured_data_tag
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_options_render_metadata_and_preloads() -> None:
|
||||||
|
html = _page(
|
||||||
|
"KarlDNS",
|
||||||
|
trusted("<main id='main'></main>"),
|
||||||
|
PageOptions(
|
||||||
|
description="Dynamic DNS",
|
||||||
|
preload_logo=True,
|
||||||
|
canonical_url="https://karldns.example/",
|
||||||
|
image_url="https://karldns.example/social.png",
|
||||||
|
image_alt="Preview",
|
||||||
|
structured_data={"name": "KarlDNS"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 'content="Dynamic DNS"' in html
|
||||||
|
assert 'rel="canonical" href="https://karldns.example/"' in html
|
||||||
|
assert 'rel="preload" as="image" href="/logo.webp?v=3"' in html
|
||||||
|
assert 'property="og:image" content="https://karldns.example/social.png"' in html
|
||||||
|
assert 'type="application/ld+json"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_social_meta_tags_include_large_card_when_image_exists() -> None:
|
||||||
|
html = _social_meta_tags(
|
||||||
|
title="KarlDNS",
|
||||||
|
description="Dynamic DNS",
|
||||||
|
canonical_url="https://karldns.de/",
|
||||||
|
image_url="https://karldns.de/social-preview.png",
|
||||||
|
image_alt="Preview",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 'property="og:title" content="KarlDNS"' in html
|
||||||
|
assert 'property="og:image" content="https://karldns.de/social-preview.png"' in html
|
||||||
|
assert 'name="twitter:card" content="summary_large_image"' in html
|
||||||
|
assert 'name="twitter:image:alt" content="Preview"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_structured_data_tag_escapes_json_for_html() -> None:
|
||||||
|
html = _structured_data_tag({"name": "KarlDNS", "description": "<dynamic>"})
|
||||||
|
|
||||||
|
assert 'type="application/ld+json"' in html
|
||||||
|
assert "<dynamic>" in html
|
||||||
|
assert "<dynamic>" not in html
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
|
from karldns.routers.management_passwords import (
|
||||||
|
_dashboard_password_error,
|
||||||
|
_dashboard_password_error_response,
|
||||||
|
_DashboardPasswordErrorContext,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_password_error_requires_minimum_length() -> None:
|
||||||
|
assert _dashboard_password_error("short", "short") == "Password must be at least 8 characters."
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_password_error_requires_confirmation_match() -> None:
|
||||||
|
assert _dashboard_password_error("correct horse", "different horse") == "The passwords do not match."
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_password_error_accepts_valid_password() -> None:
|
||||||
|
assert _dashboard_password_error("correct horse", "correct horse") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_password_error_response_renders_new_password_errors(monkeypatch) -> None:
|
||||||
|
async def fake_render_management_page_async(_service: object, context: object) -> str:
|
||||||
|
return f"message={context.message}"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"karldns.routers.management_passwords.render_management_page_async",
|
||||||
|
fake_render_management_page_async,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = asyncio.run(
|
||||||
|
_dashboard_password_error_response(
|
||||||
|
object(),
|
||||||
|
_DashboardPasswordErrorContext(
|
||||||
|
management_slug="manage-secret",
|
||||||
|
account={"hostname": "home.example.net"},
|
||||||
|
form={"dashboard_password": ["short"], "dashboard_password_confirm": ["short"]},
|
||||||
|
request_ip="198.51.100.1",
|
||||||
|
current_password_error=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(response, HTMLResponse)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert b"Password must be at least 8 characters." in response.body
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from karldns.routers.management import _activation_ip_params_from_query
|
||||||
|
from karldns.services.ddns import UpdateIpParams
|
||||||
|
from karldns.web.templates_management import ManagementPageContext, _management_page_view, _user_event_row_view
|
||||||
|
|
||||||
|
|
||||||
|
def test_management_page_context_builds_account_for_secret_urls() -> None:
|
||||||
|
context = ManagementPageContext(
|
||||||
|
management_slug="manage-secret",
|
||||||
|
account={"hostname": "home.example.net", "update_slug": "update-secret"},
|
||||||
|
csrf_token="csrf",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert context.account_for_url == {
|
||||||
|
"hostname": "home.example.net",
|
||||||
|
"update_slug": "update-secret",
|
||||||
|
"management_slug": "manage-secret",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_management_page_view_escapes_missing_status_values() -> None:
|
||||||
|
context = ManagementPageContext(
|
||||||
|
management_slug="manage-secret",
|
||||||
|
account={"hostname": "home.example.net", "update_slug": "update-secret"},
|
||||||
|
csrf_token="csrf",
|
||||||
|
)
|
||||||
|
|
||||||
|
view = _management_page_view(update_url="https://example.test/update", context=context)
|
||||||
|
|
||||||
|
assert view.hostname == "home.example.net"
|
||||||
|
assert view.ipv4 == "-"
|
||||||
|
assert view.ipv6 == "-"
|
||||||
|
assert view.updated == "Never"
|
||||||
|
|
||||||
|
|
||||||
|
def test_activation_ip_params_from_query_builds_domain_params() -> None:
|
||||||
|
assert _activation_ip_params_from_query(myip=None, ipaddr="203.0.113.8", myipv6=None, ip6addr=None) == (
|
||||||
|
UpdateIpParams(ipaddr="203.0.113.8")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_event_row_normalizes_status_badge() -> None:
|
||||||
|
row = _user_event_row_view({"status": "dns_failed", "detail": ""})
|
||||||
|
|
||||||
|
assert row.status == "DNS update failed"
|
||||||
|
assert row.badge_class == "status-badge-error"
|
||||||
|
assert row.detail == "-"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from karldns.dns.rfc2136 import _apply_record_update, _RecordUpdate
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeUpdate:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[object, ...]] = []
|
||||||
|
|
||||||
|
def replace(self, *args: object) -> None:
|
||||||
|
self.calls.append(("replace", *args))
|
||||||
|
|
||||||
|
def delete(self, *args: object) -> None:
|
||||||
|
self.calls.append(("delete", *args))
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_record_update_replaces_deletes_or_skips_record_type() -> None:
|
||||||
|
update = _FakeUpdate()
|
||||||
|
|
||||||
|
_apply_record_update(update, "home", _RecordUpdate(ttl=60, record_type="A", value="203.0.113.7", enabled=True))
|
||||||
|
_apply_record_update(update, "home", _RecordUpdate(ttl=60, record_type="AAAA", value=None, enabled=True))
|
||||||
|
_apply_record_update(update, "home", _RecordUpdate(ttl=60, record_type="TXT", value="ignored", enabled=False))
|
||||||
|
|
||||||
|
assert update.calls == [
|
||||||
|
("replace", "home", 60, "A", "203.0.113.7"),
|
||||||
|
("delete", "home", "AAAA"),
|
||||||
|
]
|
||||||
@@ -2,7 +2,6 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from contextlib import contextmanager
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -324,10 +323,8 @@ def test_rate_limit_flush_requeues_counts_after_database_failure(
|
|||||||
|
|
||||||
original_connect = store.connect
|
original_connect = store.connect
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def fail_connect():
|
def fail_connect():
|
||||||
raise RuntimeError("database temporarily unavailable")
|
raise RuntimeError("database temporarily unavailable")
|
||||||
yield
|
|
||||||
|
|
||||||
monkeypatch.setattr(store, "connect", fail_connect)
|
monkeypatch.setattr(store, "connect", fail_connect)
|
||||||
with pytest.raises(RuntimeError, match="database temporarily unavailable"):
|
with pytest.raises(RuntimeError, match="database temporarily unavailable"):
|
||||||
@@ -416,7 +413,11 @@ def test_scheduled_cleanup_failure_is_logged(tmp_path: Path, monkeypatch, caplog
|
|||||||
|
|
||||||
|
|
||||||
def test_scheduler_loop_survives_iteration_exception(tmp_path: Path, monkeypatch, caplog) -> None:
|
def test_scheduler_loop_survives_iteration_exception(tmp_path: Path, monkeypatch, caplog) -> None:
|
||||||
settings = DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin", cleanup_interval_seconds=60)
|
settings = DdnsSettings(
|
||||||
|
database_path=tmp_path / "ddns.sqlite3",
|
||||||
|
admin_password="admin",
|
||||||
|
cleanup_interval_seconds=60,
|
||||||
|
)
|
||||||
store = DdnsStore(settings.database_path)
|
store = DdnsStore(settings.database_path)
|
||||||
calls = 0
|
calls = 0
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from karldns.schemas import _account_status, _row_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_row_text_returns_none_for_missing_or_empty_values():
|
||||||
|
row = {
|
||||||
|
"empty": "",
|
||||||
|
"missing": None,
|
||||||
|
"number": 42,
|
||||||
|
"text": "hello",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert _row_text(row, "empty") is None
|
||||||
|
assert _row_text(row, "missing") is None
|
||||||
|
assert _row_text(row, "unknown") is None
|
||||||
|
assert _row_text(row, "number") == "42"
|
||||||
|
assert _row_text(row, "text") == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_status_prefers_disabled_before_lifecycle_state():
|
||||||
|
assert _account_status({"disabled": True, "accepted_at": "2026-01-01"}) == "disabled"
|
||||||
|
assert _account_status({"accepted_at": "2026-01-01"}) == "active"
|
||||||
|
assert _account_status({}) == "pending"
|
||||||
+25
-2
@@ -395,6 +395,15 @@ def test_invalid_dns_provider_is_rejected() -> None:
|
|||||||
validate_dns_provider_settings(settings)
|
validate_dns_provider_settings(settings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dns_backend_enabled_recognizes_explicit_or_legacy_rfc2136() -> None:
|
||||||
|
from karldns.dns.base import _dns_backend_enabled
|
||||||
|
|
||||||
|
assert _dns_backend_enabled("cloudflare", DdnsSettings()) is True
|
||||||
|
assert _dns_backend_enabled("rfc2136", DdnsSettings()) is True
|
||||||
|
assert _dns_backend_enabled("", DdnsSettings(rfc2136_server="192.0.2.53")) is True
|
||||||
|
assert _dns_backend_enabled("", DdnsSettings()) is False
|
||||||
|
|
||||||
|
|
||||||
def test_required_cloudflare_provider_requires_token_and_zone(tmp_path: Path) -> None:
|
def test_required_cloudflare_provider_requires_token_and_zone(tmp_path: Path) -> None:
|
||||||
with pytest.raises(RuntimeError, match="DDNS_CLOUDFLARE_API_TOKEN"):
|
with pytest.raises(RuntimeError, match="DDNS_CLOUDFLARE_API_TOKEN"):
|
||||||
make_app(
|
make_app(
|
||||||
@@ -470,7 +479,7 @@ def test_cloudflare_publish_reconciles_provider_state(monkeypatch) -> None:
|
|||||||
return {"id": "record-id", "content": "203.0.113.99"}
|
return {"id": "record-id", "content": "203.0.113.99"}
|
||||||
|
|
||||||
monkeypatch.setattr(cloudflare, "_cloudflare_find_record", fake_find)
|
monkeypatch.setattr(cloudflare, "_cloudflare_find_record", fake_find)
|
||||||
monkeypatch.setattr(cloudflare, "_cloudflare_request", lambda *args, **kwargs: {"success": True, "result": []})
|
monkeypatch.setattr(cloudflare, "_cloudflare_request", lambda *args, **_kwargs: {"success": True, "result": []})
|
||||||
|
|
||||||
with pytest.raises(DnsReconciliationError, match="did not match"):
|
with pytest.raises(DnsReconciliationError, match="did not match"):
|
||||||
cloudflare.publish_cloudflare(
|
cloudflare.publish_cloudflare(
|
||||||
@@ -496,7 +505,7 @@ def test_rfc2136_delete_checks_response_code(monkeypatch) -> None:
|
|||||||
def rcode(self) -> int:
|
def rcode(self) -> int:
|
||||||
return dns.rcode.REFUSED
|
return dns.rcode.REFUSED
|
||||||
|
|
||||||
monkeypatch.setattr(dns.query, "tcp", lambda *args, **kwargs: RefusedResponse())
|
monkeypatch.setattr(dns.query, "tcp", lambda *args, **_kwargs: RefusedResponse())
|
||||||
|
|
||||||
with pytest.raises(DnsProviderError, match="RFC2136 delete failed"):
|
with pytest.raises(DnsProviderError, match="RFC2136 delete failed"):
|
||||||
delete_rfc2136_records(
|
delete_rfc2136_records(
|
||||||
@@ -505,6 +514,20 @@ def test_rfc2136_delete_checks_response_code(monkeypatch) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rfc2136_zone_uses_explicit_setting_or_hostname_parent() -> None:
|
||||||
|
from karldns.dns.rfc2136 import _rfc2136_zone
|
||||||
|
|
||||||
|
assert _rfc2136_zone("home.example.net", DdnsSettings(rfc2136_zone="example.net")) == "example.net"
|
||||||
|
assert _rfc2136_zone("home.example.net", DdnsSettings()) == "example.net"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rfc2136_relative_name_uses_at_for_zone_apex() -> None:
|
||||||
|
from karldns.dns.rfc2136 import _rfc2136_relative_name
|
||||||
|
|
||||||
|
assert _rfc2136_relative_name("home.example.net", "example.net") == "home"
|
||||||
|
assert _rfc2136_relative_name("example.net", "example.net") == "@"
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_integer_env_value_fails_startup(monkeypatch) -> None:
|
def test_invalid_integer_env_value_fails_startup(monkeypatch) -> None:
|
||||||
monkeypatch.setenv("DDNS_RATE_LIMIT_PER_MINUTE", "not-an-int")
|
monkeypatch.setenv("DDNS_RATE_LIMIT_PER_MINUTE", "not-an-int")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from karldns.security import (
|
||||||
|
DashboardUnlockIdentity,
|
||||||
|
_dashboard_unlock_cookie_parts,
|
||||||
|
_strip_header_ip,
|
||||||
|
_trusted_proxy_candidate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_unlock_identity_payload_is_stable() -> None:
|
||||||
|
identity = DashboardUnlockIdentity("home.example.net", "manage-secret", "hash")
|
||||||
|
|
||||||
|
assert identity.payload(123, 3600) == b"home.example.net\x00manage-secret\x00hash\x00123\x003600"
|
||||||
|
|
||||||
|
|
||||||
|
def test_trusted_proxy_candidate_matches_cidr_and_single_ip() -> None:
|
||||||
|
assert _trusted_proxy_candidate("10.10.0.1", "10.0.0.0/8") is True
|
||||||
|
assert _trusted_proxy_candidate("203.0.113.10", "203.0.113.10") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_trusted_proxy_candidate_ignores_invalid_values() -> None:
|
||||||
|
assert _trusted_proxy_candidate("not-an-ip", "10.0.0.0/8") is False
|
||||||
|
assert _trusted_proxy_candidate("10.10.0.1", "not-a-network") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_unlock_cookie_parts_parse_v3_cookie() -> None:
|
||||||
|
parts = _dashboard_unlock_cookie_parts("v3:123:3600:signature")
|
||||||
|
|
||||||
|
assert parts is not None
|
||||||
|
assert parts.timestamp == 123
|
||||||
|
assert parts.max_age_seconds == 3600
|
||||||
|
assert parts.signature == "signature"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_unlock_cookie_parts_reject_invalid_cookie() -> None:
|
||||||
|
assert _dashboard_unlock_cookie_parts("v2:123:3600:signature") is None
|
||||||
|
assert _dashboard_unlock_cookie_parts("v3:not-int:3600:signature") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_header_ip_handles_common_proxy_formats() -> None:
|
||||||
|
assert _strip_header_ip('"203.0.113.10"') == "203.0.113.10"
|
||||||
|
assert _strip_header_ip("[2001:db8::10]:443") == "2001:db8::10"
|
||||||
|
assert _strip_header_ip("203.0.113.10:443") == "203.0.113.10"
|
||||||
|
assert _strip_header_ip("unknown") == ""
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from karldns.settings import _is_reserved_admin_path, _validate_launch_secrets
|
||||||
|
|
||||||
|
|
||||||
|
def test_reserved_admin_path_matches_exact_and_nested_public_routes() -> None:
|
||||||
|
assert _is_reserved_admin_path("/api")
|
||||||
|
assert _is_reserved_admin_path("/api/v1")
|
||||||
|
assert _is_reserved_admin_path("/logo.png")
|
||||||
|
assert not _is_reserved_admin_path("/ops")
|
||||||
|
assert not _is_reserved_admin_path("/apix")
|
||||||
|
|
||||||
|
|
||||||
|
def test_launch_secrets_require_strong_admin_password() -> None:
|
||||||
|
with pytest.raises(RuntimeError, match="DDNS_ADMIN_PASSWORD"):
|
||||||
|
_validate_launch_secrets("short", "")
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from karldns.store.metrics import _update_metric_increments
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_metric_increments_include_requested_outcomes() -> None:
|
||||||
|
increments = _update_metric_increments(
|
||||||
|
42.5,
|
||||||
|
is_success=True,
|
||||||
|
is_dns_failure=True,
|
||||||
|
is_reconcile_failure=False,
|
||||||
|
is_bad_ip=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert increments == {
|
||||||
|
"update_requests_total": 1,
|
||||||
|
"update_latency_ms_total": 42.5,
|
||||||
|
"update_latency_samples_total": 1,
|
||||||
|
"update_success_total": 1,
|
||||||
|
"dns_failures_total": 1,
|
||||||
|
"bad_ip_total": 1,
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from karldns.store.records import RecordUpdateInput, UpdateEventInput, _planned_record_update
|
||||||
|
|
||||||
|
|
||||||
|
def test_planned_record_update_preserves_disabled_ip_versions() -> None:
|
||||||
|
plan = _planned_record_update(
|
||||||
|
RecordUpdateInput(
|
||||||
|
hostname="home.example.net",
|
||||||
|
ipv4="203.0.113.10",
|
||||||
|
ipv6=None,
|
||||||
|
update_ipv4=True,
|
||||||
|
update_ipv6=False,
|
||||||
|
),
|
||||||
|
current_ipv4="198.51.100.4",
|
||||||
|
current_ipv6="2001:db8::4",
|
||||||
|
existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert plan.ipv4 == "203.0.113.10"
|
||||||
|
assert plan.ipv6 == "2001:db8::4"
|
||||||
|
assert plan.changed is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_event_input_preserves_event_fields() -> None:
|
||||||
|
event = UpdateEventInput(
|
||||||
|
hostname="home.example.net",
|
||||||
|
ipv4="203.0.113.10",
|
||||||
|
ipv6=None,
|
||||||
|
status="updated",
|
||||||
|
detail="ok",
|
||||||
|
source_ip="198.51.100.1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert event.hostname == "home.example.net"
|
||||||
|
assert event.ipv4 == "203.0.113.10"
|
||||||
|
assert event.source_ip == "198.51.100.1"
|
||||||
+82
-2
@@ -1,9 +1,12 @@
|
|||||||
import re
|
import re
|
||||||
|
import types
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from karldns import DdnsSettings, DdnsStore, make_app
|
from karldns import DdnsSettings, DdnsStore, make_app
|
||||||
|
from karldns.routers import assets
|
||||||
|
from karldns.web import scripts, styles
|
||||||
|
|
||||||
|
|
||||||
def _csrf(html: str) -> str:
|
def _csrf(html: str) -> str:
|
||||||
@@ -226,6 +229,32 @@ def test_dashboard_link_can_delete_hostname(tmp_path: Path) -> None:
|
|||||||
assert update.status_code == 404
|
assert update.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_ui_uses_bearnie_inspired_structural_patterns(tmp_path: Path) -> None:
|
||||||
|
database_path = tmp_path / "ddns.sqlite3"
|
||||||
|
store = DdnsStore(database_path)
|
||||||
|
account = store.create_account("home.ddns.example.net", "home")
|
||||||
|
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
home = client.get("/")
|
||||||
|
dashboard = client.get(f"/m/{account['management_slug']}")
|
||||||
|
app_css = client.get("/app.css")
|
||||||
|
|
||||||
|
assert home.status_code == 200
|
||||||
|
assert dashboard.status_code == 200
|
||||||
|
assert app_css.status_code == 200
|
||||||
|
assert 'data-ui="hero-command"' in home.text
|
||||||
|
assert 'data-slot="card"' in dashboard.text
|
||||||
|
assert 'data-slot="card-header"' in dashboard.text
|
||||||
|
assert 'data-slot="card-content"' in dashboard.text
|
||||||
|
assert 'class="ui-badge ui-badge-secret"' in dashboard.text
|
||||||
|
assert 'class="ui-badge ui-badge-public"' in dashboard.text
|
||||||
|
assert 'class="empty-state"' in dashboard.text
|
||||||
|
assert "--ui-background:" in app_css.text
|
||||||
|
assert "--ui-card:" in app_css.text
|
||||||
|
assert "--ui-muted-foreground:" in app_css.text
|
||||||
|
|
||||||
|
|
||||||
def test_management_dashboard_password_locks_dashboard_only(tmp_path: Path) -> None:
|
def test_management_dashboard_password_locks_dashboard_only(tmp_path: Path) -> None:
|
||||||
database_path = tmp_path / "ddns.sqlite3"
|
database_path = tmp_path / "ddns.sqlite3"
|
||||||
store = DdnsStore(database_path)
|
store = DdnsStore(database_path)
|
||||||
@@ -595,9 +624,9 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
|
|||||||
assert '<script src="/theme.js?v=2"></script>' in response.text
|
assert '<script src="/theme.js?v=2"></script>' in response.text
|
||||||
assert '<script src="/clipboard.js?v=3" defer></script>' in response.text
|
assert '<script src="/clipboard.js?v=3" defer></script>' in response.text
|
||||||
assert '<script src="/visitor-ip.js?v=1" defer></script>' in response.text
|
assert '<script src="/visitor-ip.js?v=1" defer></script>' in response.text
|
||||||
assert '<script src="/admin-tables.js?v=2" defer></script>' not in response.text
|
assert '<script src="/admin-tables.js?v=3" defer></script>' not in response.text
|
||||||
assert '<link href="/bootstrap-subset.css?v=3" rel="stylesheet">' in response.text
|
assert '<link href="/bootstrap-subset.css?v=3" rel="stylesheet">' in response.text
|
||||||
assert '<link href="/app.css?v=13" rel="stylesheet">' in response.text
|
assert '<link href="/app.css?v=14" rel="stylesheet">' in response.text
|
||||||
assert '<link href="/admin.css" rel="stylesheet">' not in response.text
|
assert '<link href="/admin.css" rel="stylesheet">' not in response.text
|
||||||
assert "cdn.jsdelivr.net/npm/bootstrap" not in response.text
|
assert "cdn.jsdelivr.net/npm/bootstrap" not in response.text
|
||||||
assert "<style>" not in response.text
|
assert "<style>" not in response.text
|
||||||
@@ -629,6 +658,57 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
|
|||||||
assert admin_css.headers["cache-control"] == "public, max-age=31536000, immutable"
|
assert admin_css.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_and_javascript_assets_are_served_from_files(tmp_path: Path) -> None:
|
||||||
|
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
expected_assets = {
|
||||||
|
"/app.css": "app.css",
|
||||||
|
"/admin.css": "admin.css",
|
||||||
|
"/theme.js": "theme.js",
|
||||||
|
"/clipboard.js": "clipboard.js",
|
||||||
|
"/admin-tables.js": "admin-tables.js",
|
||||||
|
"/visitor-ip.js": "visitor-ip.js",
|
||||||
|
}
|
||||||
|
|
||||||
|
for route, filename in expected_assets.items():
|
||||||
|
asset_file = assets.ASSET_DIR / filename
|
||||||
|
response = client.get(route)
|
||||||
|
|
||||||
|
assert asset_file.exists()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == asset_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_clipboard_asset_has_fallback_and_visible_failure_state(tmp_path: Path) -> None:
|
||||||
|
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
response = client.get("/clipboard.js")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'document.execCommand("copy")' in response.text
|
||||||
|
assert "Copy failed" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_visitor_ip_asset_fetches_ipv6_only_when_ipv6_placeholder_is_present(tmp_path: Path) -> None:
|
||||||
|
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
response = client.get("/visitor-ip.js")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "api6.ipify.org" in response.text
|
||||||
|
assert 'needsIpv6() || document.querySelector(".terminal-card.manual-code")' not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_style_and_script_modules_do_not_embed_assets() -> None:
|
||||||
|
assert [name for name, value in vars(styles).items() if name.endswith("_CSS") and isinstance(value, str)] == []
|
||||||
|
assert [name for name, value in vars(scripts).items() if name.endswith("_JS") and isinstance(value, str)] == []
|
||||||
|
assert isinstance(styles, types.ModuleType)
|
||||||
|
assert isinstance(scripts, types.ModuleType)
|
||||||
|
|
||||||
|
|
||||||
def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
|
def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
|
||||||
app = make_app(
|
app = make_app(
|
||||||
DdnsSettings(
|
DdnsSettings(
|
||||||
|
|||||||
@@ -55,6 +55,68 @@ def test_router_update_post_accepts_form_and_json(tmp_path: Path) -> None:
|
|||||||
assert store.list_records()[0]["ipv4"] == "203.0.113.10"
|
assert store.list_records()[0]["ipv4"] == "203.0.113.10"
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_post_json_parser_keeps_only_string_values() -> None:
|
||||||
|
from karldns.routers.updates import _json_post_values
|
||||||
|
|
||||||
|
values = _json_post_values(b'{"myip":"203.0.113.10","count":1,"empty":"","nested":{"x":1}}')
|
||||||
|
|
||||||
|
assert values == {"myip": "203.0.113.10", "empty": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_post_form_parser_handles_invalid_utf8_as_empty() -> None:
|
||||||
|
from karldns.routers.updates import _form_post_values
|
||||||
|
|
||||||
|
assert _form_post_values(b"\xff") == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_ip_params_merge_body_without_overriding_query_values() -> None:
|
||||||
|
from karldns.routers.updates import _IpParams
|
||||||
|
|
||||||
|
params = _IpParams(myip="203.0.113.8", ipaddr=None, myipv6="", ip6addr=None)
|
||||||
|
|
||||||
|
merged = params.with_body_values(
|
||||||
|
{
|
||||||
|
"myip": "203.0.113.9",
|
||||||
|
"ipaddr": "198.51.100.9",
|
||||||
|
"myipv6": "2001:db8::9",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert merged == _IpParams(
|
||||||
|
myip="203.0.113.8",
|
||||||
|
ipaddr="198.51.100.9",
|
||||||
|
myipv6="2001:db8::9",
|
||||||
|
ip6addr=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_ip_params_from_query_builds_domain_params() -> None:
|
||||||
|
from karldns.routers.updates import _ip_params_from_query
|
||||||
|
from karldns.services.ddns import UpdateIpParams
|
||||||
|
|
||||||
|
assert _ip_params_from_query(myip="203.0.113.8", ipaddr=None, myipv6=None, ip6addr="2001:db8::8") == (
|
||||||
|
UpdateIpParams(myip="203.0.113.8", ipaddr=None, myipv6=None, ip6addr="2001:db8::8")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_registration_hostname_rejects_disabled_or_unmanaged_accounts() -> None:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from karldns.routers.updates import _registration_hostname
|
||||||
|
|
||||||
|
class Service:
|
||||||
|
def hostname_is_managed(self, hostname: str) -> bool:
|
||||||
|
return hostname.endswith(".managed.example")
|
||||||
|
|
||||||
|
assert _registration_hostname({"hostname": "home.managed.example", "disabled": False}, Service()) == (
|
||||||
|
"home.managed.example"
|
||||||
|
)
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
_registration_hostname({"hostname": "home.managed.example", "disabled": True}, Service())
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
_registration_hostname({"hostname": "home.other.example", "disabled": False}, Service())
|
||||||
|
|
||||||
|
|
||||||
def test_router_update_requires_registered_hostname(tmp_path: Path) -> None:
|
def test_router_update_requires_registered_hostname(tmp_path: Path) -> None:
|
||||||
database_path = tmp_path / "ddns.sqlite3"
|
database_path = tmp_path / "ddns.sqlite3"
|
||||||
store = DdnsStore(database_path)
|
store = DdnsStore(database_path)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from karldns.web.html_utils import SafeHtml
|
||||||
|
from karldns.web.sections.public_home import (
|
||||||
|
_manual_updates_section,
|
||||||
|
_public_home_hero,
|
||||||
|
_setup_flow_section,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_home_sections_render_independently() -> None:
|
||||||
|
hero = _public_home_hero(
|
||||||
|
suffix="ddns.example.net",
|
||||||
|
demo_update_url="https://karldns.example/api/v1/router/update/<secret>?myip=<ipaddr>",
|
||||||
|
)
|
||||||
|
setup = _setup_flow_section(
|
||||||
|
create_demo="curl -sS 'https://karldns.example/api/v1/router/update/<secret>?myip=203.0.113.10'",
|
||||||
|
create_demo_command="curl -sS 'https://karldns.example/api/v1/router/update/<secret>?myip=203.0.113.10'",
|
||||||
|
)
|
||||||
|
manual = _manual_updates_section(
|
||||||
|
manual_demo="# explicit IPv4\ncurl -sS 'https://karldns.example/api/v1/router/update/<secret>?myip=203.0.113.10'",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(hero, SafeHtml)
|
||||||
|
assert 'data-ui="hero-command"' in hero
|
||||||
|
assert "ddns.example.net" in hero
|
||||||
|
assert isinstance(setup, SafeHtml)
|
||||||
|
assert "KarlDNS setup flow" in setup
|
||||||
|
assert "Copy update example" in setup
|
||||||
|
assert isinstance(manual, SafeHtml)
|
||||||
|
assert 'id="manual-updates"' in manual
|
||||||
|
assert "Copy manual update examples" in manual
|
||||||
Reference in new issue
Block a user