diff --git a/.gitignore b/.gitignore
index fb92293..0729b70 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
__pycache__/
.pytest_cache/
.ruff_cache/
+.codegraph/
.playwright-cli/
*.sqlite3
*.sqlite3-*
diff --git a/karldns/assets/admin-tables.js b/karldns/assets/admin-tables.js
new file mode 100644
index 0000000..6c0b30a
--- /dev/null
+++ b/karldns/assets/admin-tables.js
@@ -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();
+});
\ No newline at end of file
diff --git a/karldns/assets/admin.css b/karldns/assets/admin.css
new file mode 100644
index 0000000..fe4d003
--- /dev/null
+++ b/karldns/assets/admin.css
@@ -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,.dashboard-page .copy-field input{font-size:14px} .dashboard-page .copy-reveal-button{min-height:32px} .dashboard-status-card{display:block} .dashboard-status-list{display:grid;gap:0;margin:0;border:1px solid var(--rp-soft-line);border-radius:10px;overflow:hidden} .dashboard-status-list div{display:grid;grid-template-columns:82px minmax(0,1fr);border-bottom:1px solid var(--rp-soft-line)} .dashboard-status-list div:last-child{border-bottom:0} .dashboard-status-list dt,.dashboard-status-list dd{min-width:0;margin:0;padding:10px 12px;overflow-wrap:anywhere} .dashboard-status-list dt{color:var(--rp-muted);background:var(--rp-surface-alt);font-size:13px;font-weight:600} .dashboard-status-list dd{font:500 13px/1.45 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace} .dashboard-section{padding:24px 0 0} .dashboard-section-header{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:10px} .dashboard-settings-grid{display:grid;grid-template-columns:minmax(240px,0.75fr) minmax(0,1.25fr);gap:20px;align-items:start} .dashboard-form-card{display:grid;gap:12px;padding:18px} .dashboard-table-shell{max-height:360px;overflow:auto;border:1px solid var(--ui-border);border-radius:var(--ui-radius-lg);background:var(--ui-card);box-shadow:var(--ui-shadow-sm);-webkit-overflow-scrolling:touch} .dashboard-history-table{min-width:760px} .dashboard-history-table thead th{position:sticky;top:0;z-index:1;background:var(--ui-muted)} .dashboard-history-table th,.dashboard-history-table td{padding:12px 14px} .status-badge{display:inline-flex;align-items:center;min-height:24px;padding:3px 9px;border:1px solid var(--rp-line);border-radius:999px;background:var(--rp-field);color:var(--rp-charcoal);font-size:12px;font-weight:600;white-space:nowrap} .status-badge-ok{color:var(--rp-success)} .status-badge-error{color:var(--rp-danger)} .dashboard-danger-section{padding-bottom:32px} .dashboard-danger-card{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:18px;border:1px solid var(--ui-border);border-radius:var(--ui-radius-lg);background:var(--ui-card);box-shadow:var(--ui-shadow-sm)} @media (max-width:860px){.dashboard-shell{padding-top:24px} .dashboard-header,.dashboard-card-header,.dashboard-danger-card{align-items:stretch;flex-direction:column} .dashboard-grid,.dashboard-settings-grid{grid-template-columns:1fr} .dashboard-header h1{font-size:28px}} @media (max-width:560px){.dashboard-card,.dashboard-danger-card{padding:14px} .dashboard-page .copy-button,.dashboard-actions .btn,.dashboard-card-header .btn,.dashboard-danger-card .btn{width:100%} .dashboard-page .copy-field{grid-template-columns:1fr} .dashboard-status-list div{grid-template-columns:92px minmax(0,1fr)}} tr:last-child th,tr:last-child td{border-bottom:0} th{color:var(--rp-muted);font-size:12px;font-weight:600;background:var(--rp-surface-alt);white-space:nowrap} .cell-hostname{width:16rem;max-width:16rem} .cell-ip{width:10rem;max-width:12rem} .cell-time{width:13rem;max-width:13rem} .cell-status,.cell-count,.cell-delete{width:1%;white-space:nowrap} .cell-detail{min-width:18rem;max-width:28rem} .cell-actions{width:13rem} .admin-action-group{display:flex;flex-wrap:wrap;gap:6px} .admin-action-group .btn,.cell-delete .btn{min-height:34px;padding-inline:12px;white-space:nowrap} .empty{color:var(--rp-muted);text-align:center;padding:28px} .empty-state{display:grid;justify-items:center;gap:4px;padding:10px 8px;text-align:center} .empty-state-title{color:var(--ui-foreground);font-size:14px;font-weight:600} .empty-state-description{max-width:36ch;color:var(--ui-muted-foreground);font-size:13px;line-height:1.45} @media (max-width:860px){.admin-console{padding-top:24px} .admin-page-title,.admin-section-heading{align-items:start;flex-direction:column;gap:12px} .admin-cleanup-bar{grid-template-columns:1fr;align-items:start;gap:12px} .admin-cleanup-times{grid-template-columns:repeat(2,minmax(0,1fr));width:100%} .admin-cleanup-bar form{justify-self:stretch;width:100%} .admin-cleanup-bar button{width:100%} .admin-table-shell .form-control{max-width:none} .admin-table-shell [data-table-count]{white-space:normal !important} .pagination{flex-wrap:wrap;justify-content:flex-start !important} th,td{padding:10px 8px}}
\ No newline at end of file
diff --git a/karldns/assets/app.css b/karldns/assets/app.css
new file mode 100644
index 0000000..c80a05f
--- /dev/null
+++ b/karldns/assets/app.css
@@ -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:var(--rp-charcoal);font-size:13px;font-weight:600;letter-spacing:0;text-transform:none} .lead{max-width:660px;color:var(--rp-muted);font-size:16px;line-height:1.5;letter-spacing:0} .section-copy,.intro{margin-top:12px;max-width:560px;color:var(--rp-muted);font-size:15px;line-height:1.5} .install-pill{display:inline-flex;align-items:center;max-width:100%;min-width:0;min-height:48px;padding:12px 20px;border:0;border-radius:9999px;background:var(--rp-field);color:var(--rp-ink)} .install-pill code{min-width:0;overflow:hidden;color:var(--rp-ink);text-overflow:ellipsis;white-space:nowrap;font:400 15px/1.5 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace} .hero-action-form{display:inline-flex} .simple-steps{display:grid;gap:12px;max-width:620px;margin:28px auto 0;padding:0;color:var(--rp-muted);font-size:15px;line-height:1.5;list-style:none} .simple-steps li{margin:0} .simple-steps strong{color:var(--rp-ink);font-weight:500} .api-endpoints{text-align:left} .api-endpoints .simple-steps{margin-left:0;margin-right:0} .flow-steps{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:22px;max-width:760px;margin:32px auto 0;padding:0;list-style:none;text-align:center} .flow-steps li{position:relative;min-width:0;color:var(--rp-muted);font-size:14px;line-height:1.45} .flow-steps li + li::before{content:"";position:absolute;top:11px;left:-22px;width:22px;height:1px;background:var(--rp-line)} .flow-steps strong{display:block;margin-bottom:5px;color:var(--rp-ink);font-size:15px;font-weight:500} .flow-steps span{display:block;max-width:140px;margin:0 auto} .installer-commands{display:grid;gap:28px;margin-top:28px} .installer-command{display:grid;gap:0} .card{background:var(--ui-card);border:1px solid var(--ui-border);border-radius:var(--ui-radius-lg);box-shadow:var(--ui-shadow-sm);overflow:clip} .card-body{padding:24px} [data-slot="card"]{color:var(--ui-card-foreground)} [data-slot="card-header"]{min-width:0} [data-slot="card-content"]{min-width:0} [data-slot="card-footer"]{min-width:0} label{display:grid;gap:8px;color:var(--rp-charcoal);font-size:14px;font-weight:500} .form-control,input{width:100%;min-height:44px;border:1px solid var(--rp-line);border-radius:9999px;padding:10px 16px;color:var(--bs-body-color);font:inherit;background:var(--rp-field);outline:none} .form-select{min-height:38px;border-color:var(--rp-line);border-radius:9999px;background-color:var(--rp-field);color:var(--rp-ink)} .form-control:focus,input:focus,.form-select:focus{border-color:var(--rp-ink);box-shadow:0 0 0 3px var(--rp-focus-ring)} .btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border-radius:9999px;font-size:14px;font-weight:600;line-height:1;transition:background-color 0.16s ease,border-color 0.16s ease,transform 0.16s ease} .btn:not(.btn-sm){min-height:42px;padding:10px 22px} .btn-sm{min-width:40px;min-height:40px;padding-inline:16px;border-radius:9999px} .btn:active{transform:scale(0.97)} .btn:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--rp-focus-ring);outline-offset:3px} .btn-primary{--bs-btn-color:#ffffff;--bs-btn-bg:#000000;--bs-btn-border-color:#000000;--bs-btn-hover-color:#ffffff;--bs-btn-hover-bg:#1c1c1c;--bs-btn-hover-border-color:#1c1c1c;--bs-btn-active-color:#ffffff;--bs-btn-active-bg:#1c1c1c;--bs-btn-active-border-color:#1c1c1c;--bs-btn-disabled-bg:var(--rp-surface-alt);--bs-btn-disabled-border-color:var(--rp-line);--bs-btn-disabled-color:var(--rp-mute)} .btn-outline-primary{--bs-btn-color:var(--rp-ink);--bs-btn-border-color:var(--rp-line);--bs-btn-hover-color:var(--rp-ink);--bs-btn-hover-bg:var(--rp-surface-alt);--bs-btn-hover-border-color:var(--rp-line);--bs-btn-active-color:var(--rp-ink);--bs-btn-active-bg:var(--rp-surface-alt);--bs-btn-active-border-color:var(--rp-line)} .btn-outline-secondary,.btn-light{--bs-btn-bg:var(--rp-field);--bs-btn-border-color:var(--rp-line);--bs-btn-color:var(--rp-ink);--bs-btn-hover-bg:var(--rp-surface-alt);--bs-btn-hover-border-color:var(--rp-line);--bs-btn-hover-color:var(--rp-ink)} .btn-outline-danger{--bs-btn-color:var(--rp-danger);--bs-btn-border-color:var(--rp-line);--bs-btn-bg:var(--rp-field);--bs-btn-hover-color:#ffffff;--bs-btn-hover-bg:var(--rp-danger);--bs-btn-hover-border-color:var(--rp-danger);--bs-btn-active-color:#ffffff;--bs-btn-active-bg:var(--rp-danger);--bs-btn-active-border-color:var(--rp-danger)} .text-primary{color:var(--rp-ink) !important} .text-bg-primary{color:#ffffff !important;background-color:#000000 !important} .text-bg-secondary,.text-bg-warning{color:var(--rp-charcoal) !important;background-color:var(--rp-field) !important;border:1px solid var(--rp-line)} .text-bg-primary.rounded-circle{width:32px;height:32px;display:inline-grid;place-items:center;border-radius:9999px !important} .alert-info,.alert-warning{--bs-alert-color:var(--rp-charcoal);--bs-alert-bg:var(--rp-surface-alt);--bs-alert-border-color:var(--rp-line);border-radius:12px} .alert code{color:var(--rp-ink)} .ui-alert{display:flex;flex-wrap:wrap;align-items:center;gap:8px 10px} .ui-badge{display:inline-flex;align-items:center;flex:0 0 auto;min-height:22px;padding:3px 8px;border:1px solid var(--ui-border);border-radius:var(--ui-radius-sm);background:var(--ui-muted);color:var(--ui-muted-foreground);font-size:12px;font-weight:600;line-height:1;white-space:nowrap} .ui-badge-public{color:var(--rp-charcoal)} .ui-badge-secret{border-color:color-mix(in srgb,var(--ui-destructive) 28%,var(--ui-border));background:color-mix(in srgb,var(--ui-destructive) 7%,var(--ui-muted));color:var(--ui-destructive)} .copy-field{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:stretch;gap:12px;min-width:0;max-width:100%} .copy-pill{display:flex;min-width:0;min-height:58px;align-items:center;gap:8px;padding:8px 12px;border:1px solid var(--ui-border);border-radius:var(--ui-radius-md);background:var(--ui-background);box-shadow:var(--ui-shadow-sm)} .copy-field-secret .copy-pill{border-color:color-mix(in srgb,var(--ui-destructive) 32%,var(--ui-border))} .copy-pill-label{display:inline-flex;flex:0 0 auto;align-items:center;color:var(--rp-muted);font-weight:600;white-space:nowrap} .copy-field .copy-value{flex:1 1 auto;min-width:0;max-width:100%;overflow:hidden;color:var(--rp-ink);text-overflow:ellipsis;white-space:nowrap} .copy-field code,.copy-field input{margin:0;border:0;outline:0;background:transparent;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:16px;line-height:1.45} .copy-field input{width:100%;padding:0} .copy-reveal-button{flex:0 0 auto;min-height:36px;padding-inline:14px} .copy-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-width:122px;min-height:58px;font-weight:600} .copy-button .copy-icon,.copy-button .check-icon,.copy-icon-button .copy-icon,.copy-icon-button .check-icon,.copy-menu-item .copy-icon,.copy-menu-item .check-icon,.copy-menu .more-icon{width:16px;height:16px;flex:0 0 auto} .check-icon,.is-copied .copy-icon{display:none} .is-copied .check-icon{display:block} .terminal-card{position:relative;display:grid;gap:8px;padding:18px 20px;border:1px solid var(--ui-border);border-radius:var(--ui-radius-lg);color:var(--rp-terminal-ink);background:var(--rp-terminal);font:400 14px/1.5 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;text-align:left;box-shadow:var(--ui-shadow-sm)} .code-window{padding-right:56px} .manual-code{overflow-x:auto} .terminal-card code{display:block;overflow-wrap:anywhere;color:inherit;font:inherit} .copy-icon-button{position:absolute;top:8px;right:8px;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;padding:0;border:0;border-radius:999px;color:var(--rp-muted);background:transparent} .copy-icon-button:hover,.copy-icon-button:focus-visible{color:var(--rp-ink);background:var(--rp-surface-alt)} .copy-actions{display:flex;align-items:center;justify-content:flex-start} .copy-menu{position:relative;display:inline-flex} .copy-menu > summary{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;border:1px solid var(--rp-line);border-radius:999px;color:var(--rp-muted);background:var(--rp-surface);cursor:pointer;list-style:none} .copy-menu > summary::-webkit-details-marker{display:none} .copy-menu > summary:hover,.copy-menu > summary:focus-visible{color:var(--rp-ink);background:var(--rp-surface-alt)} .copy-menu-popover{position:absolute;z-index:20;top:calc(100% + 8px);left:0;display:grid;min-width:220px;gap:4px;padding:8px;border:1px solid var(--rp-line);border-radius:12px;background:var(--rp-surface)} .copy-menu-item{display:inline-flex;align-items:center;justify-content:flex-start;gap:8px;width:100%;min-height:36px;padding:8px 10px;border:0;border-radius:10px;color:var(--rp-ink);background:transparent;font:500 14px/1.2 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-align:left} .copy-menu-item:hover,.copy-menu-item:focus-visible{background:var(--rp-surface-alt)} .site-footer{padding:28px 0;background:var(--rp-canvas)} .footer-inner{display:flex;align-items:center;justify-content:space-between;gap:20px} .footer-made{flex:0 0 auto;color:var(--rp-muted);font-size:13px;line-height:1.4} .footer-links{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:8px 18px} .footer-link{display:inline-flex;align-items:center;gap:6px;color:var(--rp-muted);font-size:13px;line-height:1.4;text-decoration:none} .footer-link svg{width:15px;height:15px;flex:0 0 auto;opacity:0.8} .footer-link:hover,.footer-link:focus-visible{color:var(--rp-ink)} .footer-link:focus-visible{border-radius:999px;outline:2px solid var(--rp-focus-ring);outline-offset:4px} @media (max-width:860px){.section,.hero-band{padding:64px 0} .section-compact{padding:48px 0} .hero-band{min-height:auto} h1{font-size:32px}} @media (max-width:720px){.flow-steps{grid-template-columns:repeat(2,minmax(0,1fr));max-width:440px} .flow-steps li + li::before{display:none}} @media (max-width:560px){.container,.container.narrow,.hero-band .container{width:100%;max-width:100%} .site-nav{padding:10px 0} .site-nav .container{align-items:flex-start} .site-nav-actions{max-width:calc(100% - 108px);row-gap:8px} .brand{font-size:14px} .brand img{width:24px;height:24px} .navbar .nav-link,.navbar-text{font-size:12px} .nav-label{display:none} .flow-steps{grid-template-columns:1fr;gap:18px;max-width:280px;margin-top:28px} .flow-steps li + li::before{display:none} .flow-steps span{max-width:240px} .hero-band .btn,.hero-action-form,.card button,.danger-section form,.danger-section button{width:100%} .install-pill{width:100%} .hero-logo{width:68px;height:68px} .copy-field{display:grid;grid-template-columns:1fr;gap:10px} .copy-pill{display:grid;grid-template-columns:minmax(0,1fr);min-height:50px;align-items:start;border-radius:24px;padding:12px 14px;overflow:hidden} .copy-pill-label{display:block;min-width:0;max-width:100%} .copy-field .copy-value,.copy-field code{display:block;width:100%;max-width:100%;min-width:0;font-size:14px;line-height:1.55;white-space:normal;overflow-wrap:anywhere;word-break:break-word;overflow:visible;text-overflow:clip} .copy-field input.copy-value{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .terminal-card{max-width:100%;overflow:hidden} .terminal-card code{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word} .manual-code{overflow-x:hidden} .copy-field .copy-button{width:100%;min-height:50px;border-radius:9999px} .card .copy-icon-button,.copy-icon-button{width:34px} .card .copy-menu > summary,.copy-menu > summary{width:40px} .copy-menu-popover{right:auto;left:0} .footer-inner{align-items:flex-start;flex-direction:column} .footer-links{justify-content:flex-start} h1{font-size:28px;line-height:1.15} h2{font-size:24px}}
\ No newline at end of file
diff --git a/karldns/assets/clipboard.js b/karldns/assets/clipboard.js
new file mode 100644
index 0000000..26d36c2
--- /dev/null
+++ b/karldns/assets/clipboard.js
@@ -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");
+ });
+});
diff --git a/karldns/assets/theme.js b/karldns/assets/theme.js
new file mode 100644
index 0000000..fb2930c
--- /dev/null
+++ b/karldns/assets/theme.js
@@ -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);
+ });
+})();
\ No newline at end of file
diff --git a/karldns/assets/visitor-ip.js b/karldns/assets/visitor-ip.js
new file mode 100644
index 0000000..2555bc2
--- /dev/null
+++ b/karldns/assets/visitor-ip.js
@@ -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);
+ });
+})();
diff --git a/karldns/dependencies.py b/karldns/dependencies.py
index d425bdc..95aef54 100644
--- a/karldns/dependencies.py
+++ b/karldns/dependencies.py
@@ -8,7 +8,7 @@ from fastapi.responses import PlainTextResponse
from .dns import DnsProviderError
from .security import credentials_from_basic_auth
-from .services.ddns import BadIpError, DdnsService
+from .services.ddns import BadIpError, DdnsService, UpdateIpParams
from .settings import DdnsSettings
from .store import DdnsStore
@@ -38,11 +38,8 @@ def authenticate_admin(
if not settings.admin_password:
raise HTTPException(status_code=500, detail="DDNS_ADMIN_PASSWORD is required")
- supplied = None
- if authorization and authorization.lower().startswith("basic "):
- _, supplied = credentials_from_basic_auth(authorization)
-
- if not supplied or not secrets.compare_digest(supplied, settings.admin_password):
+ supplied = _basic_auth_password(authorization)
+ if supplied is None or not secrets.compare_digest(supplied, settings.admin_password):
raise HTTPException(
status_code=401,
detail="admin authentication required",
@@ -50,17 +47,21 @@ def authenticate_admin(
)
+def _basic_auth_password(authorization: str | None) -> str | None:
+ if authorization is None or not authorization.lower().startswith("basic "):
+ return None
+ _, password = credentials_from_basic_auth(authorization)
+ return password
+
+
def apply_update_response(
service: DdnsService,
request: Request,
hostname: str,
- myip: str | None,
- ipaddr: str | None,
- myipv6: str | None,
- ip6addr: str | None,
+ ip_params: UpdateIpParams,
) -> str | PlainTextResponse:
try:
- return service.apply_update(request, hostname, myip, ipaddr, myipv6, ip6addr).result.dyndns_response
+ return service.apply_update(request, hostname, ip_params).result.dyndns_response
except BadIpError:
return PlainTextResponse("badip", status_code=400)
except DnsProviderError:
diff --git a/karldns/dns/base.py b/karldns/dns/base.py
index 3a690c4..c06d94b 100644
--- a/karldns/dns/base.py
+++ b/karldns/dns/base.py
@@ -55,7 +55,7 @@ def delete_dns_records(hostname: str, settings: DdnsSettings) -> None:
def dns_backend_configured(settings: DdnsSettings) -> bool:
validate_dns_provider_settings(settings)
provider = _normalized_provider(settings)
- return provider == "cloudflare" or provider == "rfc2136" or bool(settings.rfc2136_server)
+ return _dns_backend_enabled(provider, settings)
def validate_dns_provider_settings(settings: DdnsSettings) -> None:
@@ -65,19 +65,33 @@ def validate_dns_provider_settings(settings: DdnsSettings) -> None:
if not settings.require_dns_provider:
return
if provider == "cloudflare":
- if not settings.cloudflare_api_token or not settings.cloudflare_zone_id:
- raise DnsConfigurationError(
- "DDNS_CLOUDFLARE_API_TOKEN and DDNS_CLOUDFLARE_ZONE_ID are required when DDNS_DNS_PROVIDER=cloudflare"
- )
+ _validate_cloudflare_settings(settings)
return
- if provider == "rfc2136" or settings.rfc2136_server:
- if not settings.rfc2136_server or not settings.rfc2136_zone:
- raise DnsConfigurationError(
- "DDNS_RFC2136_SERVER and DDNS_RFC2136_ZONE are required when RFC2136 DNS publishing is enabled"
- )
+ if _dns_backend_enabled(provider, settings):
+ _validate_rfc2136_settings(settings)
return
raise DnsConfigurationError("DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured")
+def _dns_backend_enabled(provider: str, settings: DdnsSettings) -> bool:
+ return provider == "cloudflare" or provider == "rfc2136" or bool(settings.rfc2136_server)
+
+
+def _validate_cloudflare_settings(settings: DdnsSettings) -> None:
+ if settings.cloudflare_api_token and settings.cloudflare_zone_id:
+ return
+ raise DnsConfigurationError(
+ "DDNS_CLOUDFLARE_API_TOKEN and DDNS_CLOUDFLARE_ZONE_ID are required when DDNS_DNS_PROVIDER=cloudflare"
+ )
+
+
+def _validate_rfc2136_settings(settings: DdnsSettings) -> None:
+ if settings.rfc2136_server and settings.rfc2136_zone:
+ return
+ raise DnsConfigurationError(
+ "DDNS_RFC2136_SERVER and DDNS_RFC2136_ZONE are required when RFC2136 DNS publishing is enabled"
+ )
+
+
def _normalized_provider(settings: DdnsSettings) -> str:
return settings.dns_provider.strip().lower()
diff --git a/karldns/dns/cloudflare.py b/karldns/dns/cloudflare.py
index 6ee2600..1614547 100644
--- a/karldns/dns/cloudflare.py
+++ b/karldns/dns/cloudflare.py
@@ -78,15 +78,23 @@ def _cloudflare_request(
request = urllib.request.Request(url, data=body, method=method)
request.add_header("Authorization", f"Bearer {settings.cloudflare_api_token}")
request.add_header("Content-Type", "application/json")
+ parsed = _read_cloudflare_json(request)
+ _raise_for_cloudflare_error(parsed)
+ return parsed
+
+
+def _read_cloudflare_json(request: urllib.request.Request) -> dict[str, Any]:
try:
with urllib.request.urlopen(request, timeout=10) as response:
- parsed = json.loads(response.read().decode("utf-8"))
+ return cast(dict[str, Any], json.loads(response.read().decode("utf-8")))
except urllib.error.HTTPError as exc:
raise DnsProviderError(f"Cloudflare API returned HTTP {exc.code}") from exc
except urllib.error.URLError as exc:
raise DnsProviderError("Cloudflare API request failed") from exc
except json.JSONDecodeError as exc:
raise DnsProviderError("Cloudflare API returned invalid JSON") from exc
+
+
+def _raise_for_cloudflare_error(parsed: dict[str, Any]) -> None:
if not parsed.get("success", False):
raise DnsProviderError("Cloudflare API returned an unsuccessful response")
- return parsed
diff --git a/karldns/dns/rfc2136.py b/karldns/dns/rfc2136.py
index 5f8918e..f5413ca 100644
--- a/karldns/dns/rfc2136.py
+++ b/karldns/dns/rfc2136.py
@@ -1,67 +1,114 @@
from __future__ import annotations
+from dataclasses import dataclass
+from typing import Any, Protocol
+
from ..models import UpdateResult
from ..settings import DdnsSettings
from .base import DnsProviderError
+class _RcodeResponse(Protocol):
+ def rcode(self) -> int: ...
+
+
+@dataclass(frozen=True, slots=True)
+class _RecordUpdate:
+ ttl: int
+ record_type: str
+ value: str | None
+ enabled: bool
+
+
def publish_rfc2136(result: UpdateResult, settings: DdnsSettings) -> None:
if not settings.rfc2136_server:
return
- import dns.exception
- import dns.query
- import dns.rcode
- import dns.tsigkeyring
- import dns.update
-
- keyring = None
- if settings.rfc2136_key_name and settings.rfc2136_key_secret:
- keyring = dns.tsigkeyring.from_text({settings.rfc2136_key_name: settings.rfc2136_key_secret})
-
- zone = settings.rfc2136_zone or ".".join(result.hostname.split(".")[1:])
- update = dns.update.Update(zone, keyring=keyring)
- relative = result.hostname.removesuffix("." + zone).rstrip(".") or "@"
- if result.update_ipv4 and result.ipv4:
- update.replace(relative, settings.ttl, "A", result.ipv4)
- elif result.update_ipv4:
- update.delete(relative, "A")
- if result.update_ipv6 and result.ipv6:
- update.replace(relative, settings.ttl, "AAAA", result.ipv6)
- elif result.update_ipv6:
- update.delete(relative, "AAAA")
- try:
- response = dns.query.tcp(update, settings.rfc2136_server, timeout=5)
- except dns.exception.DNSException as exc:
- raise DnsProviderError("RFC2136 DNS update failed") from exc
- rcode = response.rcode()
- if rcode != 0:
- raise DnsProviderError(f"RFC2136 update failed: {dns.rcode.to_text(rcode)}")
+ update, relative = _rfc2136_update(result.hostname, settings)
+ _apply_update_records(update, relative, result, settings)
+ _send_rfc2136_update(update, settings.rfc2136_server, "update")
def delete_rfc2136_records(hostname: str, settings: DdnsSettings) -> None:
if not settings.rfc2136_server:
return
+ update, relative = _rfc2136_update(hostname, settings)
+ update.delete(relative, "A")
+ update.delete(relative, "AAAA")
+ _send_rfc2136_update(update, settings.rfc2136_server, "delete")
+
+
+def _rfc2136_update(hostname: str, settings: DdnsSettings) -> tuple[Any, str]:
+ import dns.update
+
+ zone = _rfc2136_zone(hostname, settings)
+ update = dns.update.Update(zone, keyring=_rfc2136_keyring(settings))
+ return update, _rfc2136_relative_name(hostname, zone)
+
+
+def _rfc2136_zone(hostname: str, settings: DdnsSettings) -> str:
+ return settings.rfc2136_zone or ".".join(hostname.split(".")[1:])
+
+
+def _rfc2136_relative_name(hostname: str, zone: str) -> str:
+ hostname = hostname.rstrip(".")
+ zone = zone.rstrip(".")
+ if hostname == zone:
+ return "@"
+ return hostname.removesuffix("." + zone).rstrip(".") or "@"
+
+
+def _rfc2136_keyring(settings: DdnsSettings) -> Any | None:
+ if not settings.rfc2136_key_name or not settings.rfc2136_key_secret:
+ return None
+
+ import dns.tsigkeyring
+
+ return dns.tsigkeyring.from_text({settings.rfc2136_key_name: settings.rfc2136_key_secret})
+
+
+def _apply_update_records(update: Any, relative: str, result: UpdateResult, settings: DdnsSettings) -> None:
+ _apply_record_update(
+ update,
+ relative,
+ _RecordUpdate(ttl=settings.ttl, record_type="A", value=result.ipv4, enabled=result.update_ipv4),
+ )
+ _apply_record_update(
+ update,
+ relative,
+ _RecordUpdate(ttl=settings.ttl, record_type="AAAA", value=result.ipv6, enabled=result.update_ipv6),
+ )
+
+
+def _apply_record_update(
+ update: Any,
+ relative: str,
+ record: _RecordUpdate,
+) -> None:
+ if not record.enabled:
+ return
+ if record.value is None:
+ update.delete(relative, record.record_type)
+ return
+ update.replace(relative, record.ttl, record.record_type, record.value)
+
+
+def _send_rfc2136_update(update: Any, server: str, operation: str) -> None:
import dns.exception
import dns.query
import dns.rcode
- import dns.tsigkeyring
- import dns.update
- keyring = None
- if settings.rfc2136_key_name and settings.rfc2136_key_secret:
- keyring = dns.tsigkeyring.from_text({settings.rfc2136_key_name: settings.rfc2136_key_secret})
-
- zone = settings.rfc2136_zone or ".".join(hostname.split(".")[1:])
- update = dns.update.Update(zone, keyring=keyring)
- relative = hostname.removesuffix("." + zone).rstrip(".") or "@"
- update.delete(relative, "A")
- update.delete(relative, "AAAA")
try:
- response = dns.query.tcp(update, settings.rfc2136_server, timeout=5)
+ response = dns.query.tcp(update, server, timeout=5)
except dns.exception.DNSException as exc:
- raise DnsProviderError("RFC2136 DNS delete failed") from exc
+ raise DnsProviderError(f"RFC2136 DNS {operation} failed") from exc
+ _raise_for_rfc2136_response(response, operation)
+
+
+def _raise_for_rfc2136_response(response: _RcodeResponse, operation: str) -> None:
+ import dns.rcode
+
rcode = response.rcode()
if rcode != 0:
- raise DnsProviderError(f"RFC2136 delete failed: {dns.rcode.to_text(rcode)}")
+ raise DnsProviderError(f"RFC2136 {operation} failed: {dns.rcode.to_text(dns.rcode.Rcode(rcode))}")
diff --git a/karldns/routers/admin.py b/karldns/routers/admin.py
index fc2a7ca..7d55fb5 100644
--- a/karldns/routers/admin.py
+++ b/karldns/routers/admin.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+from typing import Annotated
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
@@ -18,72 +19,67 @@ from ..services.ddns import normalize_hostname
from ..web.components import _copy_row
from ..web.forms import first_form_value, request_form
from ..web.html_utils import SafeHtml
-from ..web.templates_admin import _admin_notice, _admin_secret_panel, _render_admin_page
+from ..web.templates_admin import _admin_notice, _admin_page_context, _admin_secret_panel, _render_admin_page
ADMIN_TABLE_LIMIT = 100
ADMIN_JSON_OFFSET_CAP = 10_000
+AdminAuthDep = Annotated[None, Depends(authenticate_admin)]
+AdminOffsetQuery = Annotated[int, Query(ge=0)]
+CleanupDoneQuery = Annotated[bool, Query()]
+CleanupCountQuery = Annotated[int, Query(ge=0)]
+
+
+def _admin_table_offsets(
+ accounts_offset: AdminOffsetQuery = 0,
+ cleanup_runs_offset: AdminOffsetQuery = 0,
+ update_events_offset: AdminOffsetQuery = 0,
+) -> dict[str, int]:
+ return {
+ "accounts": accounts_offset,
+ "cleanup_runs": cleanup_runs_offset,
+ "update_events": update_events_offset,
+ }
+
+
+def _admin_cleanup_notice(
+ cleanup_done: CleanupDoneQuery = False,
+ cleanup_unused: CleanupCountQuery = 0,
+ cleanup_events: CleanupCountQuery = 0,
+) -> SafeHtml | None:
+ if not cleanup_done:
+ return None
+ return _admin_notice(
+ "success",
+ f"Cleanup finished. Removed {cleanup_unused} hostnames and {cleanup_events} old events.",
+ )
+
+
+AdminTableOffsetsDep = Annotated[dict[str, int], Depends(_admin_table_offsets)]
+AdminCleanupNoticeDep = Annotated[SafeHtml | None, Depends(_admin_cleanup_notice)]
def make_router(admin_path: str = "/admin") -> APIRouter:
router = APIRouter()
def render_admin(
- service: ServiceDep,
settings: SettingsDep,
store: StoreDep,
table_offsets: dict[str, int] | None = None,
notice: SafeHtml | None = None,
) -> str:
table_offsets = table_offsets or {}
- return _render_admin_page(
- service,
- settings,
- store.list_accounts(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("accounts", 0)),
- store.list_cleanup_runs(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("cleanup_runs", 0)),
- store.list_scheduled_tasks(),
- store.list_update_events(ADMIN_TABLE_LIMIT, offset=table_offsets.get("update_events", 0)),
- store.get_service_metrics(),
- {
- "accounts": store.count_accounts(),
- "cleanup_runs": store.count_cleanup_runs(),
- "update_events": store.count_update_events(),
- },
- table_offsets,
- ADMIN_TABLE_LIMIT,
- notice,
- )
+ context = _admin_page_context(store, table_offsets, ADMIN_TABLE_LIMIT, notice)
+ return _render_admin_page(settings, context)
@router.get(admin_path, response_class=HTMLResponse, include_in_schema=False)
def admin(
settings: SettingsDep,
store: StoreDep,
- service: ServiceDep,
- _: None = Depends(authenticate_admin),
- accounts_offset: int = Query(0, ge=0),
- cleanup_runs_offset: int = Query(0, ge=0),
- update_events_offset: int = Query(0, ge=0),
- cleanup_done: bool = Query(False),
- cleanup_unused: int = Query(0, ge=0),
- cleanup_events: int = Query(0, ge=0),
+ _: AdminAuthDep,
+ table_offsets: AdminTableOffsetsDep,
+ notice: AdminCleanupNoticeDep,
) -> str:
- notice = (
- _admin_notice(
- "success", f"Cleanup finished. Removed {cleanup_unused} hostnames and {cleanup_events} old events."
- )
- if cleanup_done
- else None
- )
- return render_admin(
- service,
- settings,
- store,
- {
- "accounts": accounts_offset,
- "cleanup_runs": cleanup_runs_offset,
- "update_events": update_events_offset,
- },
- notice,
- )
+ return render_admin(settings, store, table_offsets, notice)
@router.post(f"{admin_path}/hostnames/delete", include_in_schema=False)
async def admin_delete_account(
@@ -91,7 +87,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
- _: None = Depends(authenticate_admin),
+ _: AdminAuthDep,
) -> Response:
form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf"))
@@ -118,7 +114,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
- _: None = Depends(authenticate_admin),
+ _: AdminAuthDep,
) -> Response:
form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf"))
@@ -168,7 +164,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
)
else:
raise HTTPException(status_code=400, detail="invalid rotation action")
- page = await run_in_threadpool(render_admin, service, settings, store, None, notice)
+ page = await run_in_threadpool(render_admin, settings, store, None, notice)
return HTMLResponse(page)
@router.post(f"{admin_path}/cleanup", include_in_schema=False)
@@ -176,8 +172,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
request: Request,
settings: SettingsDep,
store: StoreDep,
- service: ServiceDep,
- _: None = Depends(authenticate_admin),
+ _: AdminAuthDep,
) -> Response:
form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf"))
diff --git a/karldns/routers/api.py b/karldns/routers/api.py
index ea1f186..e0b70db 100644
--- a/karldns/routers/api.py
+++ b/karldns/routers/api.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+from typing import Annotated
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from starlette.concurrency import run_in_threadpool
@@ -33,6 +35,12 @@ router = APIRouter(prefix="/api/v1")
ADMIN_API_LIMIT_CAP = 500
ADMIN_API_OFFSET_CAP = 10_000
HOSTNAME_HISTORY_LIMIT_CAP = 100
+AdminAuthDep = Annotated[None, Depends(authenticate_admin)]
+HostnameHistoryLimitQuery = Annotated[int, Query(ge=0, le=HOSTNAME_HISTORY_LIMIT_CAP)]
+AdminLimitQuery = Annotated[int, Query(ge=1, le=ADMIN_API_LIMIT_CAP)]
+AdminOffsetQuery = Annotated[int, Query(ge=0, le=ADMIN_API_OFFSET_CAP)]
+AdminDashboardLimitQuery = Annotated[int, Query(ge=1, le=ADMIN_API_LIMIT_CAP)]
+AdminBeforeQuery = Annotated[str | None, Query(max_length=64)]
@router.post(
@@ -52,7 +60,10 @@ async def create_hostname(service: ServiceDep) -> HostnameCreateResponse:
"/hostnames/{dashboard_secret}",
response_model=HostnameStatusResponse,
summary="Read a hostname by private dashboard link",
- description="Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
+ description=(
+ "Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, "
+ "not this API endpoint."
+ ),
tags=["hostnames"],
responses={404: {"model": ErrorResponse}},
)
@@ -60,7 +71,7 @@ async def read_hostname(
dashboard_secret: ManagementSlugPath,
store: StoreDep,
service: ServiceDep,
- history_limit: int = Query(10, ge=0, le=HOSTNAME_HISTORY_LIMIT_CAP),
+ history_limit: HostnameHistoryLimitQuery = 10,
) -> HostnameStatusResponse:
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
if not account or account["disabled"]:
@@ -113,7 +124,10 @@ async def rotate_hostname_update_url(
"/hostnames/{dashboard_secret}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a hostname by private dashboard link",
- description="Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
+ description=(
+ "Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, "
+ "not this API endpoint."
+ ),
tags=["hostnames"],
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
@@ -140,8 +154,8 @@ async def delete_hostname(dashboard_secret: ManagementSlugPath, store: StoreDep,
)
async def read_admin(
store: StoreDep,
- _: None = Depends(authenticate_admin),
- limit: int = Query(25, ge=1, le=ADMIN_API_LIMIT_CAP),
+ _: AdminAuthDep,
+ limit: AdminDashboardLimitQuery = 25,
) -> AdminDashboardResponse:
metrics = await run_in_threadpool(store.get_service_metrics)
accounts = await run_in_threadpool(store.list_accounts, limit, 0)
@@ -175,9 +189,9 @@ async def read_admin(
)
async def list_admin_hostnames(
store: StoreDep,
- _: None = Depends(authenticate_admin),
- limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
- offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
+ _: AdminAuthDep,
+ limit: AdminLimitQuery = 100,
+ offset: AdminOffsetQuery = 0,
) -> AdminHostnamesResponse:
accounts = await run_in_threadpool(store.list_accounts, limit, offset)
total = await run_in_threadpool(store.count_accounts)
@@ -200,10 +214,10 @@ async def list_admin_hostnames(
)
async def list_admin_events(
store: StoreDep,
- _: None = Depends(authenticate_admin),
- limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
- offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
- before: str | None = Query(None, max_length=64),
+ _: AdminAuthDep,
+ limit: AdminLimitQuery = 100,
+ offset: AdminOffsetQuery = 0,
+ before: AdminBeforeQuery = None,
) -> AdminEventsResponse:
items = (
await run_in_threadpool(store.list_update_events_before, limit, before)
@@ -232,9 +246,9 @@ async def list_admin_events(
)
async def list_admin_records(
store: StoreDep,
- _: None = Depends(authenticate_admin),
- limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
- offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
+ _: AdminAuthDep,
+ limit: AdminLimitQuery = 100,
+ offset: AdminOffsetQuery = 0,
) -> AdminRecordsResponse:
items = await run_in_threadpool(store.list_records_page, limit, offset)
total = await run_in_threadpool(store.count_records)
diff --git a/karldns/routers/assets.py b/karldns/routers/assets.py
index c623ee3..01befd5 100644
--- a/karldns/routers/assets.py
+++ b/karldns/routers/assets.py
@@ -5,31 +5,11 @@ from pathlib import Path
from fastapi import APIRouter, Response
from fastapi.responses import FileResponse, RedirectResponse
-from ..web.scripts import ADMIN_TABLES_JS, CLIPBOARD_JS, THEME_JS, VISITOR_IP_JS
-from ..web.styles import ADMIN_CSS, BASE_CSS
-
ASSET_DIR = Path(__file__).parents[1] / "assets"
router = APIRouter(include_in_schema=False)
ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
-def _minify_css(css: str) -> str:
- return (
- " ".join(css.split())
- .replace(" {", "{")
- .replace("{ ", "{")
- .replace("; ", ";")
- .replace(": ", ":")
- .replace(", ", ",")
- .replace(" }", "}")
- .replace(";}", "}")
- )
-
-
-MINIFIED_BASE_CSS = _minify_css(BASE_CSS)
-MINIFIED_ADMIN_CSS = _minify_css(ADMIN_CSS)
-
-
@router.get("/favicon.ico")
def favicon() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS)
@@ -115,33 +95,33 @@ def site_webmanifest() -> Response:
@router.get("/theme.js")
-def theme_js() -> Response:
- return Response(THEME_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
+def theme_js() -> FileResponse:
+ return FileResponse(ASSET_DIR / "theme.js", media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/clipboard.js")
-def clipboard_js() -> Response:
- return Response(CLIPBOARD_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
+def clipboard_js() -> FileResponse:
+ return FileResponse(ASSET_DIR / "clipboard.js", media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/admin-tables.js")
-def admin_tables_js() -> Response:
- return Response(ADMIN_TABLES_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
+def admin_tables_js() -> FileResponse:
+ return FileResponse(ASSET_DIR / "admin-tables.js", media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/visitor-ip.js")
-def visitor_ip_js() -> Response:
- return Response(VISITOR_IP_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
+def visitor_ip_js() -> FileResponse:
+ return FileResponse(ASSET_DIR / "visitor-ip.js", media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/app.css")
-def app_css() -> Response:
- return Response(MINIFIED_BASE_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS)
+def app_css() -> FileResponse:
+ return FileResponse(ASSET_DIR / "app.css", media_type="text/css", headers=ASSET_CACHE_HEADERS)
@router.get("/admin.css")
-def admin_css() -> Response:
- return Response(MINIFIED_ADMIN_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS)
+def admin_css() -> FileResponse:
+ return FileResponse(ASSET_DIR / "admin.css", media_type="text/css", headers=ASSET_CACHE_HEADERS)
@router.get("/install.sh")
diff --git a/karldns/routers/management.py b/karldns/routers/management.py
index bb60a01..397510d 100644
--- a/karldns/routers/management.py
+++ b/karldns/routers/management.py
@@ -1,8 +1,8 @@
from __future__ import annotations
-from typing import NoReturn
+from typing import Annotated, NoReturn
-from fastapi import APIRouter, HTTPException, Request, Response
+from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
@@ -14,11 +14,13 @@ from ..event_statuses import (
DASHBOARD_UPDATE_URL_ROTATED,
)
from ..security import client_ip, dashboard_csrf_token, require_dashboard_csrf
-from ..services.ddns import BadIpError
+from ..services.ddns import BadIpError, UpdateIpParams
from ..web.forms import first_form_value, request_form
from ..web.html_utils import trusted
from ..web.layout import _page
from ..web.templates_management import (
+ ManagementPageContext,
+ PendingManagementPageContext,
_render_management_page,
_render_management_unlock_page,
_render_pending_management_page,
@@ -36,6 +38,19 @@ from .path_params import ManagementSlugPath
router = APIRouter()
router.include_router(management_passwords.router)
+ActivationIpQuery = Annotated[str | None, Query()]
+
+
+def _activation_ip_params_from_query(
+ myip: ActivationIpQuery = None,
+ ipaddr: ActivationIpQuery = None,
+ myipv6: ActivationIpQuery = None,
+ ip6addr: ActivationIpQuery = None,
+) -> UpdateIpParams:
+ return UpdateIpParams(myip=myip, ipaddr=ipaddr, myipv6=myipv6, ip6addr=ip6addr)
+
+
+ActivationIpParamsDep = Annotated[UpdateIpParams, Depends(_activation_ip_params_from_query)]
@router.get("/m/{management_slug}", response_class=HTMLResponse, include_in_schema=False)
@@ -43,27 +58,34 @@ def manage_dashboard(
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
- store: StoreDep,
service: ServiceDep,
activated: bool = False,
) -> str:
- account = get_active_management_account(store, management_slug)
+ account = get_active_management_account(service.store, management_slug)
csrf_token = dashboard_csrf_token(management_slug, account)
if not management_account_is_accepted(account):
return _render_pending_management_page(
- service, settings, management_slug, account, csrf_token, request_ip=client_ip(request, settings)
+ service,
+ PendingManagementPageContext(
+ settings=settings,
+ management_slug=management_slug,
+ account=account,
+ csrf_token=csrf_token,
+ request_ip=client_ip(request, settings),
+ ),
)
if not has_management_access(request, settings, management_slug, account):
return _render_management_unlock_page(management_slug, account, csrf_token)
message = "Hostname registered. Add the update URL to your router." if activated else None
return _render_management_page(
service,
- settings,
- management_slug,
- account,
- csrf_token,
- message=message,
- request_ip=client_ip(request, settings),
+ ManagementPageContext(
+ management_slug=management_slug,
+ account=account,
+ csrf_token=csrf_token,
+ message=message,
+ request_ip=client_ip(request, settings),
+ ),
)
@@ -72,30 +94,36 @@ async def activate_dashboard(
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
- store: StoreDep,
service: ServiceDep,
- myip: str | None = None,
- ipaddr: str | None = None,
- myipv6: str | None = None,
- ip6addr: str | None = None,
+ ip_params: ActivationIpParamsDep,
) -> RedirectResponse | HTMLResponse:
- account = await get_active_management_account_async(store, management_slug)
+ account = await get_active_management_account_async(service.store, management_slug)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
if not management_account_is_accepted(account):
try:
- await run_in_threadpool(service.publish_initial_dns, request, hostname, myip, ipaddr, myipv6, ip6addr)
+ await run_in_threadpool(
+ service.publish_initial_dns,
+ request,
+ hostname,
+ ip_params,
+ )
except BadIpError:
return HTMLResponse(
_render_pending_management_page(
service,
- settings,
- management_slug,
- account,
- dashboard_csrf_token(management_slug, account),
- "The supplied IP address is invalid. Leave IP fields empty and let the router send the first update.",
- client_ip(request, settings),
+ PendingManagementPageContext(
+ settings=settings,
+ management_slug=management_slug,
+ account=account,
+ csrf_token=dashboard_csrf_token(management_slug, account),
+ message=(
+ "The supplied IP address is invalid. Leave IP fields empty and let the router "
+ "send the first update."
+ ),
+ request_ip=client_ip(request, settings),
+ ),
),
status_code=400,
)
@@ -103,20 +131,22 @@ async def activate_dashboard(
return HTMLResponse(
_render_pending_management_page(
service,
- settings,
- management_slug,
- account,
- dashboard_csrf_token(management_slug, account),
- "DNS registration failed. The hostname was not registered.",
- client_ip(request, settings),
+ PendingManagementPageContext(
+ settings=settings,
+ management_slug=management_slug,
+ account=account,
+ csrf_token=dashboard_csrf_token(management_slug, account),
+ message="DNS registration failed. The hostname was not registered.",
+ request_ip=client_ip(request, settings),
+ ),
),
status_code=500,
)
- activated_account = await run_in_threadpool(lambda: store.activate_account(hostname))
+ activated_account = await run_in_threadpool(lambda: service.store.activate_account(hostname))
if not activated_account:
return _not_found()
await run_in_threadpool(
- store.log_update_event,
+ service.store.log_update_event,
hostname,
None,
None,
@@ -140,12 +170,14 @@ async def rotate_dashboard_secrets(
return HTMLResponse(
_render_pending_management_page(
service,
- settings,
- management_slug,
- account,
- dashboard_csrf_token(management_slug, account),
- "Register this hostname first.",
- client_ip(request, settings),
+ PendingManagementPageContext(
+ settings=settings,
+ management_slug=management_slug,
+ account=account,
+ csrf_token=dashboard_csrf_token(management_slug, account),
+ message="Register this hostname first.",
+ request_ip=client_ip(request, settings),
+ ),
),
status_code=400,
)
@@ -171,12 +203,13 @@ async def rotate_dashboard_secrets(
)
return await render_management_page_async(
service,
- settings,
- management_slug,
- rotated,
- dashboard_csrf_token(management_slug, rotated),
- message="Update URL changed. Update your router now.",
- request_ip=request_ip,
+ ManagementPageContext(
+ management_slug=management_slug,
+ account=rotated,
+ csrf_token=dashboard_csrf_token(management_slug, rotated),
+ message="Update URL changed. Update your router now.",
+ request_ip=request_ip,
+ ),
)
raise _bad_rotation_action()
diff --git a/karldns/routers/management_passwords.py b/karldns/routers/management_passwords.py
index ef57ad4..4426d66 100644
--- a/karldns/routers/management_passwords.py
+++ b/karldns/routers/management_passwords.py
@@ -1,5 +1,8 @@
from __future__ import annotations
+from collections.abc import Mapping
+from dataclasses import dataclass
+
from fastapi import APIRouter, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
@@ -7,9 +10,11 @@ from starlette.concurrency import run_in_threadpool
from ..dependencies import ServiceDep, SettingsDep, StoreDep
from ..event_statuses import DASHBOARD_PASSWORD_CHANGED, DASHBOARD_PASSWORD_ENABLED, DASHBOARD_PASSWORD_REMOVED
from ..security import client_ip, dashboard_csrf_token, require_dashboard_csrf
+from ..store import DdnsStore
from ..web.forms import first_form_value, request_form
-from ..web.templates_management import _render_management_unlock_page
+from ..web.templates_management import ManagementPageContext, _render_management_unlock_page
from .management_shared import (
+ ManagementAccount,
delete_unlock_cookie,
get_active_management_account_async,
has_management_access,
@@ -23,6 +28,15 @@ from .path_params import ManagementSlugPath
router = APIRouter()
+@dataclass(frozen=True, slots=True)
+class _DashboardPasswordErrorContext:
+ management_slug: str
+ account: ManagementAccount
+ form: dict[str, list[str]]
+ request_ip: str
+ current_password_error: str | None
+
+
@router.post("/m/{management_slug}/unlock", include_in_schema=False)
async def unlock_dashboard(
management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep
@@ -70,45 +84,21 @@ async def set_dashboard_password(
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
- if account.get("dashboard_password_hash"):
- is_current = await run_in_threadpool(
- store.verify_dashboard_password,
- hostname,
- first_form_value(form, "current_dashboard_password"),
- )
- if not is_current:
- return await management_error_response_async(
- service,
- settings,
- management_slug,
- account,
- "Current password is incorrect.",
- client_ip(request, settings),
- )
+ current_password_error = await _verify_current_dashboard_password(store, hostname, account, form)
+ request_ip = client_ip(request, settings)
+ error_response = await _dashboard_password_error_response(
+ service,
+ _DashboardPasswordErrorContext(
+ management_slug=management_slug,
+ account=account,
+ form=form,
+ request_ip=request_ip,
+ current_password_error=current_password_error,
+ ),
+ )
+ if error_response:
+ return error_response
password = first_form_value(form, "dashboard_password")
- confirm = first_form_value(form, "dashboard_password_confirm")
- if len(password) < 8:
- page = await render_management_page_async(
- service,
- settings,
- management_slug,
- account,
- dashboard_csrf_token(management_slug, account),
- message="Password must be at least 8 characters.",
- request_ip=client_ip(request, settings),
- )
- return HTMLResponse(page, status_code=400)
- if password != confirm:
- page = await render_management_page_async(
- service,
- settings,
- management_slug,
- account,
- dashboard_csrf_token(management_slug, account),
- message="The passwords do not match.",
- request_ip=client_ip(request, settings),
- )
- return HTMLResponse(page, status_code=400)
had_password = bool(account.get("dashboard_password_hash"))
await run_in_threadpool(store.set_dashboard_password, hostname, password)
account = await get_active_management_account_async(store, management_slug)
@@ -126,6 +116,61 @@ async def set_dashboard_password(
return response
+def _dashboard_password_error(password: str, confirm: str) -> str | None:
+ if len(password) < 8:
+ return "Password must be at least 8 characters."
+ if password != confirm:
+ return "The passwords do not match."
+ return None
+
+
+async def _dashboard_password_error_response(
+ service: ServiceDep,
+ context: _DashboardPasswordErrorContext,
+) -> HTMLResponse | None:
+ if context.current_password_error:
+ return await management_error_response_async(
+ service,
+ context.management_slug,
+ context.account,
+ context.current_password_error,
+ context.request_ip,
+ )
+ password_error = _dashboard_password_error(
+ first_form_value(context.form, "dashboard_password"),
+ first_form_value(context.form, "dashboard_password_confirm"),
+ )
+ if password_error is None:
+ return None
+ page = await render_management_page_async(
+ service,
+ ManagementPageContext(
+ management_slug=context.management_slug,
+ account=context.account,
+ csrf_token=dashboard_csrf_token(context.management_slug, context.account),
+ message=password_error,
+ request_ip=context.request_ip,
+ ),
+ )
+ return HTMLResponse(page, status_code=400)
+
+
+async def _verify_current_dashboard_password(
+ store: DdnsStore,
+ hostname: str,
+ account: Mapping[str, object],
+ form: dict[str, list[str]],
+) -> str | None:
+ if not account.get("dashboard_password_hash"):
+ return None
+ is_current = await run_in_threadpool(
+ store.verify_dashboard_password,
+ hostname,
+ first_form_value(form, "current_dashboard_password"),
+ )
+ return None if is_current else "Current password is incorrect."
+
+
@router.post("/m/{management_slug}/password/remove", include_in_schema=False)
async def remove_dashboard_password(
management_slug: ManagementSlugPath,
@@ -152,7 +197,6 @@ async def remove_dashboard_password(
if not is_current:
return await management_error_response_async(
service,
- settings,
management_slug,
account,
"Current password is incorrect.",
diff --git a/karldns/routers/management_shared.py b/karldns/routers/management_shared.py
index 49586b3..c0b33cd 100644
--- a/karldns/routers/management_shared.py
+++ b/karldns/routers/management_shared.py
@@ -6,6 +6,7 @@ from starlette.concurrency import run_in_threadpool
from ..security import (
DASHBOARD_UNLOCK_COOKIE,
+ DashboardUnlockIdentity,
dashboard_cookie_signing_secret,
dashboard_csrf_token,
sign_dashboard_unlock_cookie,
@@ -15,7 +16,7 @@ from ..services.ddns import DdnsService
from ..settings import DdnsSettings
from ..store import DdnsStore
from ..store.types import AccountRow
-from ..web.templates_management import _render_management_page, _render_management_unlock_page
+from ..web.templates_management import ManagementPageContext, _render_management_page, _render_management_unlock_page
ManagementAccount = AccountRow
@@ -59,7 +60,6 @@ def locked_management_response(management_slug: str, account: ManagementAccount)
def management_error_response(
service: DdnsService,
- settings: DdnsSettings,
management_slug: str,
account: ManagementAccount,
message: str,
@@ -68,12 +68,13 @@ def management_error_response(
return HTMLResponse(
_render_management_page(
service,
- settings,
- management_slug,
- account,
- dashboard_csrf_token(management_slug, account),
- message=message,
- request_ip=request_ip,
+ ManagementPageContext(
+ management_slug=management_slug,
+ account=account,
+ csrf_token=dashboard_csrf_token(management_slug, account),
+ message=message,
+ request_ip=request_ip,
+ ),
),
status_code=400,
)
@@ -81,35 +82,22 @@ def management_error_response(
async def management_error_response_async(
service: DdnsService,
- settings: DdnsSettings,
management_slug: str,
account: ManagementAccount,
message: str,
request_ip: str | None = None,
) -> HTMLResponse:
- return await run_in_threadpool(
- management_error_response, service, settings, management_slug, account, message, request_ip
- )
+ return await run_in_threadpool(management_error_response, service, management_slug, account, message, request_ip)
async def render_management_page_async(
service: DdnsService,
- settings: DdnsSettings,
- management_slug: str,
- account: ManagementAccount,
- csrf_token: str,
- message: str | None = None,
- request_ip: str | None = None,
+ context: ManagementPageContext,
) -> str:
return await run_in_threadpool(
_render_management_page,
service,
- settings,
- management_slug,
- account,
- csrf_token,
- message,
- request_ip,
+ context,
)
@@ -127,9 +115,7 @@ def set_unlock_cookie(
response.set_cookie(
DASHBOARD_UNLOCK_COOKIE,
sign_dashboard_unlock_cookie(
- str(account["hostname"]),
- management_slug,
- str(password_hash),
+ DashboardUnlockIdentity(str(account["hostname"]), management_slug, str(password_hash)),
signing_secret,
max_age_seconds,
),
diff --git a/karldns/routers/updates.py b/karldns/routers/updates.py
index 284cf2a..2bed829 100644
--- a/karldns/routers/updates.py
+++ b/karldns/routers/updates.py
@@ -1,20 +1,36 @@
from __future__ import annotations
import json
+from collections.abc import Mapping
+from typing import Annotated
from urllib.parse import parse_qs
-from fastapi import APIRouter, HTTPException, Query, Request
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse
from starlette.concurrency import run_in_threadpool
-from ..dependencies import ServiceDep, StoreDep, apply_update_response
+from ..dependencies import ServiceDep, apply_update_response
from ..dns import DnsProviderError
from ..event_statuses import DASHBOARD_HOSTNAME_ACTIVATED
from ..security import client_ip
-from ..services.ddns import BadIpError
+from ..services.ddns import BadIpError, DdnsService, UpdateIpParams
from .path_params import ManagementSlugPath, UpdateSlugPath
router = APIRouter()
+RouterIpQuery = Annotated[str | None, Query()]
+_IpParams = UpdateIpParams
+
+
+def _ip_params_from_query(
+ myip: RouterIpQuery = None,
+ ipaddr: RouterIpQuery = None,
+ myipv6: RouterIpQuery = None,
+ ip6addr: RouterIpQuery = None,
+) -> UpdateIpParams:
+ return UpdateIpParams(myip=myip, ipaddr=ipaddr, myipv6=myipv6, ip6addr=ip6addr)
+
+
+RouterIpParamsDep = Annotated[UpdateIpParams, Depends(_ip_params_from_query)]
@router.get(
@@ -27,14 +43,15 @@ router = APIRouter()
async def register_hostname_get(
dashboard_secret: ManagementSlugPath,
request: Request,
- store: StoreDep,
service: ServiceDep,
- myip: str | None = Query(default=None),
- ipaddr: str | None = Query(default=None),
- myipv6: str | None = Query(default=None),
- ip6addr: str | None = Query(default=None),
+ ip_params: RouterIpParamsDep,
) -> str | PlainTextResponse:
- return await _register_hostname(dashboard_secret, request, store, service, myip, ipaddr, myipv6, ip6addr)
+ return await _register_hostname(
+ dashboard_secret,
+ request,
+ service,
+ ip_params,
+ )
@router.post(
@@ -47,45 +64,76 @@ async def register_hostname_get(
async def register_hostname_post(
dashboard_secret: ManagementSlugPath,
request: Request,
- store: StoreDep,
service: ServiceDep,
- myip: str | None = Query(default=None),
- ipaddr: str | None = Query(default=None),
- myipv6: str | None = Query(default=None),
- ip6addr: str | None = Query(default=None),
+ ip_params: RouterIpParamsDep,
) -> str | PlainTextResponse:
- return await _register_hostname(dashboard_secret, request, store, service, myip, ipaddr, myipv6, ip6addr)
+ return await _register_hostname(
+ dashboard_secret,
+ request,
+ service,
+ ip_params,
+ )
async def _register_hostname(
dashboard_secret: str,
request: Request,
- store: StoreDep,
service: ServiceDep,
- myip: str | None,
- ipaddr: str | None,
- myipv6: str | None,
- ip6addr: str | None,
+ ip_params: _IpParams,
) -> str | PlainTextResponse:
- myip, ipaddr, myipv6, ip6addr = await _ip_params_from_request(request, myip, ipaddr, myipv6, ip6addr)
- account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
- if not account or account["disabled"]:
+ ip_params = await _ip_params_from_request(request, ip_params)
+ account = await run_in_threadpool(service.store.get_account_by_management_slug, dashboard_secret)
+ hostname = _registration_hostname(account, service)
+ assert account is not None
+ dns_error = await _publish_initial_registration_dns(request, service, account, hostname, ip_params)
+ if dns_error is not None:
+ return dns_error
+ activated = await run_in_threadpool(service.store.activate_account, hostname)
+ if not activated:
+ raise HTTPException(status_code=404, detail="not found")
+ await _log_registration_activation(request, service, hostname)
+ return "good registered"
+
+
+def _registration_hostname(account: Mapping[str, object] | None, service: DdnsService) -> str:
+ if not account or account.get("disabled"):
raise HTTPException(status_code=404, detail="not found")
hostname = str(account["hostname"])
if not service.hostname_is_managed(hostname):
raise HTTPException(status_code=404, detail="not found")
- if not account["accepted_at"]:
- try:
- await run_in_threadpool(service.publish_initial_dns, request, hostname, myip, ipaddr, myipv6, ip6addr)
- except BadIpError as exc:
- return PlainTextResponse(f"badip {exc}", status_code=400)
- except DnsProviderError:
- return PlainTextResponse("911 dns registration failed", status_code=500)
- activated = await run_in_threadpool(store.activate_account, hostname)
- if not activated:
- raise HTTPException(status_code=404, detail="not found")
+ return hostname
+
+
+async def _publish_initial_registration_dns(
+ request: Request,
+ service: DdnsService,
+ account: Mapping[str, object],
+ hostname: str,
+ ip_params: _IpParams,
+) -> PlainTextResponse | None:
+ if account.get("accepted_at"):
+ return None
+ try:
+ await run_in_threadpool(
+ service.publish_initial_dns,
+ request,
+ hostname,
+ ip_params,
+ )
+ except BadIpError as exc:
+ return PlainTextResponse(f"badip {exc}", status_code=400)
+ except DnsProviderError:
+ return PlainTextResponse("911 dns registration failed", status_code=500)
+ return None
+
+
+async def _log_registration_activation(
+ request: Request,
+ service: DdnsService,
+ hostname: str,
+) -> None:
await run_in_threadpool(
- store.log_update_event,
+ service.store.log_update_event,
hostname,
None,
None,
@@ -93,7 +141,6 @@ async def _register_hostname(
"hostname registered",
client_ip(request, service.settings),
)
- return "good registered"
@router.get(
@@ -106,14 +153,15 @@ async def _register_hostname(
async def update_hostname_get(
update_slug: UpdateSlugPath,
request: Request,
- store: StoreDep,
service: ServiceDep,
- myip: str | None = Query(default=None),
- ipaddr: str | None = Query(default=None),
- myipv6: str | None = Query(default=None),
- ip6addr: str | None = Query(default=None),
+ ip_params: RouterIpParamsDep,
) -> str | PlainTextResponse:
- return await _update_hostname(update_slug, request, store, service, myip, ipaddr, myipv6, ip6addr)
+ return await _update_hostname(
+ update_slug,
+ request,
+ service,
+ ip_params,
+ )
@router.post(
@@ -126,76 +174,78 @@ async def update_hostname_get(
async def update_hostname_post(
update_slug: UpdateSlugPath,
request: Request,
- store: StoreDep,
service: ServiceDep,
- myip: str | None = Query(default=None),
- ipaddr: str | None = Query(default=None),
- myipv6: str | None = Query(default=None),
- ip6addr: str | None = Query(default=None),
+ ip_params: RouterIpParamsDep,
) -> str | PlainTextResponse:
- return await _update_hostname(update_slug, request, store, service, myip, ipaddr, myipv6, ip6addr)
+ return await _update_hostname(
+ update_slug,
+ request,
+ service,
+ ip_params,
+ )
async def _update_hostname(
update_slug: str,
request: Request,
- store: StoreDep,
service: ServiceDep,
- myip: str | None,
- ipaddr: str | None,
- myipv6: str | None,
- ip6addr: str | None,
+ ip_params: _IpParams,
) -> str | PlainTextResponse:
- myip, ipaddr, myipv6, ip6addr = await _ip_params_from_request(request, myip, ipaddr, myipv6, ip6addr)
- account = await run_in_threadpool(store.get_account_by_slug, update_slug)
+ ip_params = await _ip_params_from_request(request, ip_params)
+ account = await run_in_threadpool(service.store.get_account_by_slug, update_slug)
if not account or account["disabled"] or not account["accepted_at"]:
raise HTTPException(status_code=404, detail="not found")
if not service.hostname_is_managed(str(account["hostname"])):
raise HTTPException(status_code=404, detail="not found")
return await run_in_threadpool(
- apply_update_response, service, request, str(account["hostname"]), myip, ipaddr, myipv6, ip6addr
+ apply_update_response,
+ service,
+ request,
+ str(account["hostname"]),
+ ip_params,
)
-async def _ip_params_from_request(
- request: Request,
- myip: str | None,
- ipaddr: str | None,
- myipv6: str | None,
- ip6addr: str | None,
-) -> tuple[str | None, str | None, str | None, str | None]:
+async def _ip_params_from_request(request: Request, ip_params: _IpParams) -> _IpParams:
if request.method != "POST":
- return myip, ipaddr, myipv6, ip6addr
+ return ip_params
body_values = await _post_values(request)
- return (
- myip or body_values.get("myip"),
- ipaddr or body_values.get("ipaddr"),
- myipv6 or body_values.get("myipv6"),
- ip6addr or body_values.get("ip6addr"),
- )
+ return ip_params.with_body_values(body_values)
async def _post_values(request: Request) -> dict[str, str]:
- content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
body = await request.body()
if not body:
return {}
+ content_type = _request_content_type(request)
if content_type == "application/json":
- try:
- data = json.loads(body)
- except json.JSONDecodeError:
- return {}
- if not isinstance(data, dict):
- return {}
- return {str(key): str(value) for key, value in data.items() if isinstance(value, str)}
+ return _json_post_values(body)
if content_type == "application/x-www-form-urlencoded":
- try:
- form_body = body.decode("utf-8", errors="strict")
- except UnicodeDecodeError:
- return {}
- try:
- parsed = parse_qs(form_body, keep_blank_values=True, max_num_fields=8)
- except ValueError:
- return {}
- return {key: values[0] for key, values in parsed.items() if values}
+ return _form_post_values(body)
return {}
+
+
+def _request_content_type(request: Request) -> str:
+ return request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
+
+
+def _json_post_values(body: bytes) -> dict[str, str]:
+ try:
+ data = json.loads(body)
+ except json.JSONDecodeError:
+ return {}
+ if not isinstance(data, dict):
+ return {}
+ return {str(key): value for key, value in data.items() if isinstance(value, str)}
+
+
+def _form_post_values(body: bytes) -> dict[str, str]:
+ try:
+ form_body = body.decode("utf-8", errors="strict")
+ except UnicodeDecodeError:
+ return {}
+ try:
+ parsed = parse_qs(form_body, keep_blank_values=True, max_num_fields=8)
+ except ValueError:
+ return {}
+ return {key: values[0] for key, values in parsed.items() if values}
diff --git a/karldns/schemas.py b/karldns/schemas.py
index 1a59d12..6dddf57 100644
--- a/karldns/schemas.py
+++ b/karldns/schemas.py
@@ -153,16 +153,14 @@ def _hostname_status(
account: Mapping[str, object],
history: Sequence[Mapping[str, object]] | None = None,
) -> HostnameStatusResponse:
- account_for_url = {"update_slug": str(account["update_slug"])}
- account_for_dashboard = {"management_slug": str(account.get("management_slug", ""))}
return HostnameStatusResponse(
hostname=str(account["hostname"]),
- status="active" if account.get("accepted_at") else "pending",
- update_url=service.fritz_update_url(account_for_url),
- dashboard_url=service.dashboard_url(account_for_dashboard) if account_for_dashboard["management_slug"] else "",
- ipv4=str(account["ipv4"]) if account.get("ipv4") else None,
- ipv6=str(account["ipv6"]) if account.get("ipv6") else None,
- updated_at=str(account["updated_at"]) if account.get("updated_at") else None,
+ status=_account_status(account, include_disabled=False),
+ update_url=service.fritz_update_url({"update_slug": str(account["update_slug"])}),
+ dashboard_url=_dashboard_url(service, account),
+ ipv4=_row_text(account, "ipv4"),
+ ipv6=_row_text(account, "ipv6"),
+ updated_at=_row_text(account, "updated_at"),
history=[_hostname_update_event(event) for event in history or []],
)
@@ -181,8 +179,8 @@ def _hostname_rotate_update_response(
def _hostname_update_event(row: Mapping[str, object]) -> HostnameUpdateEvent:
return HostnameUpdateEvent(
hostname=str(row["hostname"]),
- ipv4=str(row["ipv4"]) if row.get("ipv4") else None,
- ipv6=str(row["ipv6"]) if row.get("ipv6") else None,
+ ipv4=_row_text(row, "ipv4"),
+ ipv6=_row_text(row, "ipv6"),
status=str(row["status"]),
detail=str(row["detail"]),
created_at=str(row["created_at"]),
@@ -193,12 +191,12 @@ def _hostname_update_event(row: Mapping[str, object]) -> HostnameUpdateEvent:
def _admin_hostname(account: Mapping[str, object]) -> AdminHostname:
return AdminHostname(
hostname=str(account["hostname"]),
- status="disabled" if account.get("disabled") else ("active" if account.get("accepted_at") else "pending"),
- ipv4=str(account["ipv4"]) if account.get("ipv4") else None,
- ipv6=str(account["ipv6"]) if account.get("ipv6") else None,
- updated_at=str(account["updated_at"]) if account.get("updated_at") else None,
- created_at=str(account["created_at"]) if account.get("created_at") else None,
- accepted_at=str(account["accepted_at"]) if account.get("accepted_at") else None,
+ status=_account_status(account),
+ ipv4=_row_text(account, "ipv4"),
+ ipv6=_row_text(account, "ipv6"),
+ updated_at=_row_text(account, "updated_at"),
+ created_at=_row_text(account, "created_at"),
+ accepted_at=_row_text(account, "accepted_at"),
disabled=bool(account.get("disabled")),
)
@@ -212,8 +210,8 @@ def _admin_metrics(metrics: dict[str, float]) -> AdminMetrics:
def _admin_record(row: Mapping[str, object]) -> AdminRecord:
return AdminRecord(
hostname=str(row["hostname"]),
- ipv4=str(row["ipv4"]) if row.get("ipv4") else None,
- ipv6=str(row["ipv6"]) if row.get("ipv6") else None,
+ ipv4=_row_text(row, "ipv4"),
+ ipv6=_row_text(row, "ipv6"),
updated_at=str(row["updated_at"]),
)
@@ -221,8 +219,8 @@ def _admin_record(row: Mapping[str, object]) -> AdminRecord:
def _admin_update_event(row: Mapping[str, object]) -> AdminUpdateEvent:
return AdminUpdateEvent(
hostname=str(row["hostname"]),
- ipv4=str(row["ipv4"]) if row.get("ipv4") else None,
- ipv6=str(row["ipv6"]) if row.get("ipv6") else None,
+ ipv4=_row_text(row, "ipv4"),
+ ipv6=_row_text(row, "ipv6"),
status=str(row["status"]),
detail=str(row["detail"]),
source_ip=str(row["source_ip"]),
@@ -243,16 +241,36 @@ def _admin_scheduled_task(row: Mapping[str, object]) -> AdminScheduledTask:
name=str(row["name"]),
interval_seconds=_int_value(row["interval_seconds"], 0),
next_run_at=str(row["next_run_at"]),
- lease_owner=str(row["lease_owner"]) if row.get("lease_owner") else None,
- lease_expires_at=str(row["lease_expires_at"]) if row.get("lease_expires_at") else None,
- last_started_at=str(row["last_started_at"]) if row.get("last_started_at") else None,
- last_finished_at=str(row["last_finished_at"]) if row.get("last_finished_at") else None,
+ lease_owner=_row_text(row, "lease_owner"),
+ lease_expires_at=_row_text(row, "lease_expires_at"),
+ last_started_at=_row_text(row, "last_started_at"),
+ last_finished_at=_row_text(row, "last_finished_at"),
last_status=str(row["last_status"]),
last_error=str(row["last_error"]),
updated_at=str(row["updated_at"]),
)
+def _row_text(row: Mapping[str, object], key: str) -> str | None:
+ value = row.get(key)
+ return str(value) if value else None
+
+
+def _account_status(account: Mapping[str, object], *, include_disabled: bool = True) -> str:
+ if include_disabled and account.get("disabled"):
+ return "disabled"
+ if account.get("accepted_at"):
+ return "active"
+ return "pending"
+
+
+def _dashboard_url(service: DdnsService, account: Mapping[str, object]) -> str:
+ management_slug = _row_text(account, "management_slug")
+ if management_slug is None:
+ return ""
+ return service.dashboard_url({"management_slug": management_slug})
+
+
def _int_value(value: object, default: int) -> int:
if value is None:
return default
diff --git a/karldns/security.py b/karldns/security.py
index 89c027e..c58cccf 100644
--- a/karldns/security.py
+++ b/karldns/security.py
@@ -6,6 +6,7 @@ import hmac
import secrets
import time
from collections.abc import Mapping
+from dataclasses import dataclass
from datetime import UTC, datetime
from ipaddress import ip_address, ip_network
@@ -32,6 +33,23 @@ PRIVATE_PROXY_NETWORKS = tuple(
)
+@dataclass(frozen=True, slots=True)
+class _DashboardUnlockCookieParts:
+ timestamp: int
+ max_age_seconds: int
+ signature: str
+
+
+@dataclass(frozen=True, slots=True)
+class DashboardUnlockIdentity:
+ hostname: str
+ management_slug: str
+ password_hash: str
+
+ def payload(self, timestamp: int, max_age_seconds: int) -> bytes:
+ return f"{self.hostname}\0{self.management_slug}\0{self.password_hash}\0{timestamp}\0{max_age_seconds}".encode()
+
+
def credentials_from_basic_auth(header: str) -> tuple[str | None, str | None]:
try:
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
@@ -69,9 +87,7 @@ def new_bearer_secret() -> str:
def sign_dashboard_unlock_cookie(
- hostname: str,
- management_slug: str,
- password_hash: str,
+ identity: DashboardUnlockIdentity,
signing_secret: str,
max_age_seconds: int,
now: int | None = None,
@@ -79,9 +95,7 @@ def sign_dashboard_unlock_cookie(
timestamp = int(time.time() if now is None else now)
bounded_max_age = _bounded_dashboard_unlock_seconds(max_age_seconds)
signature = _dashboard_unlock_signature(
- hostname,
- management_slug,
- password_hash,
+ identity,
signing_secret,
timestamp,
bounded_max_age,
@@ -96,40 +110,63 @@ def verify_dashboard_unlock_cookie(
signing_secret: str,
cookie_value: str | None,
) -> bool:
- if not password_hash or not signing_secret or not cookie_value:
+ identity = _dashboard_unlock_identity(hostname, management_slug, password_hash)
+ parts = _active_dashboard_unlock_cookie_parts(cookie_value)
+ if identity is None or not signing_secret or parts is None:
return False
+ expected = _dashboard_unlock_signature(
+ identity,
+ signing_secret,
+ parts.timestamp,
+ parts.max_age_seconds,
+ )
+ return hmac.compare_digest(parts.signature, expected)
+
+
+def _dashboard_unlock_identity(
+ hostname: str,
+ management_slug: str,
+ password_hash: str | None,
+) -> DashboardUnlockIdentity | None:
+ if not password_hash:
+ return None
+ return DashboardUnlockIdentity(hostname, management_slug, password_hash)
+
+
+def _active_dashboard_unlock_cookie_parts(cookie_value: str | None) -> _DashboardUnlockCookieParts | None:
+ if cookie_value is None:
+ return None
+ parts = _dashboard_unlock_cookie_parts(cookie_value)
+ if parts is None:
+ return None
+ if _dashboard_unlock_cookie_expired(parts, int(time.time())):
+ return None
+ return parts
+
+
+def _dashboard_unlock_cookie_parts(cookie_value: str) -> _DashboardUnlockCookieParts | None:
try:
- version, raw_timestamp, raw_max_age, supplied = cookie_value.split(":", 3)
+ version, raw_timestamp, raw_max_age, signature = cookie_value.split(":", 3)
timestamp = int(raw_timestamp)
max_age_seconds = _bounded_dashboard_unlock_seconds(int(raw_max_age))
except ValueError:
- return False
+ return None
if version != "v3":
- return False
- now = int(time.time())
- if timestamp > now + 60 or now - timestamp > max_age_seconds:
- return False
- expected = _dashboard_unlock_signature(
- hostname,
- management_slug,
- password_hash,
- signing_secret,
- timestamp,
- max_age_seconds,
- )
- return hmac.compare_digest(supplied, expected)
+ return None
+ return _DashboardUnlockCookieParts(timestamp, max_age_seconds, signature)
+
+
+def _dashboard_unlock_cookie_expired(parts: _DashboardUnlockCookieParts, now: int) -> bool:
+ return parts.timestamp > now + 60 or now - parts.timestamp > parts.max_age_seconds
def _dashboard_unlock_signature(
- hostname: str,
- management_slug: str,
- password_hash: str,
+ identity: DashboardUnlockIdentity,
signing_secret: str,
timestamp: int,
max_age_seconds: int,
) -> str:
- payload = f"{hostname}\0{management_slug}\0{password_hash}\0{timestamp}\0{max_age_seconds}".encode()
- return hmac.new(signing_secret.encode(), payload, hashlib.sha256).hexdigest()
+ return hmac.new(signing_secret.encode(), identity.payload(timestamp, max_age_seconds), hashlib.sha256).hexdigest()
def dashboard_cookie_signing_secret(settings: DdnsSettings) -> str:
@@ -186,19 +223,20 @@ def rate_limit_bucket(path: str) -> str:
def _trusted_proxy(peer: str, trusted: set[str]) -> bool:
if not trusted:
return False
+ return any(_trusted_proxy_candidate(peer, candidate) for candidate in trusted)
+
+
+def _trusted_proxy_candidate(peer: str, candidate: str) -> bool:
try:
peer_ip = ip_address(peer)
except ValueError:
return False
- for candidate in trusted:
- try:
- if "/" in candidate and peer_ip in ip_network(candidate, strict=False):
- return True
- if "/" not in candidate and peer_ip == ip_address(candidate):
- return True
- except ValueError:
- continue
- return False
+ try:
+ if "/" in candidate:
+ return peer_ip in ip_network(candidate, strict=False)
+ return peer_ip == ip_address(candidate)
+ except ValueError:
+ return False
def _should_trust_proxy_headers(peer: str, settings: DdnsSettings | None) -> bool:
@@ -250,13 +288,28 @@ def _valid_header_ip(value: str) -> str | None:
def _strip_header_ip(value: str) -> str:
- cleaned = value.strip().strip('"').strip("'")
- if not cleaned or cleaned.lower() == "unknown":
+ cleaned = _strip_header_ip_quotes(value)
+ if _empty_header_ip(cleaned):
return ""
- if cleaned.startswith("[") and "]" in cleaned:
- return cleaned[1 : cleaned.index("]")]
- if cleaned.count(":") == 1:
- host, port = cleaned.rsplit(":", 1)
- if port.isdigit():
- return host
- return cleaned
+ return _strip_bracketed_ip(cleaned) or _strip_host_port(cleaned) or cleaned
+
+
+def _strip_header_ip_quotes(value: str) -> str:
+ return value.strip().strip('"').strip("'")
+
+
+def _empty_header_ip(value: str) -> bool:
+ return not value or value.lower() == "unknown"
+
+
+def _strip_bracketed_ip(value: str) -> str:
+ if value.startswith("[") and "]" in value:
+ return value[1 : value.index("]")]
+ return ""
+
+
+def _strip_host_port(value: str) -> str:
+ if value.count(":") != 1:
+ return ""
+ host, port = value.rsplit(":", 1)
+ return host if port.isdigit() else ""
diff --git a/karldns/services/ddns.py b/karldns/services/ddns.py
index df77eec..cbd5b91 100644
--- a/karldns/services/ddns.py
+++ b/karldns/services/ddns.py
@@ -31,6 +31,35 @@ class UpdateOutcome:
source_ip: str
+@dataclass(frozen=True, slots=True)
+class UpdateIpParams:
+ myip: str | None = None
+ ipaddr: str | None = None
+ myipv6: str | None = None
+ ip6addr: str | None = None
+
+ @property
+ def initial_plan_args(self) -> tuple[str | None, str | None, str | None, str | None]:
+ return self.myip, self.ipaddr, self.myipv6, self.ip6addr
+
+ def with_body_values(self, body_values: Mapping[str, str]) -> UpdateIpParams:
+ return UpdateIpParams(
+ myip=self.myip or body_values.get("myip"),
+ ipaddr=self.ipaddr or body_values.get("ipaddr"),
+ myipv6=self.myipv6 or body_values.get("myipv6"),
+ ip6addr=self.ip6addr or body_values.get("ip6addr"),
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class InitialDnsPlan:
+ ipv4: str | None
+ ipv6: str | None
+ update_ipv4: bool
+ update_ipv6: bool
+ detail: str
+
+
class BadIpError(ValueError):
"""Raised when a supplied DynDNS IP parameter is invalid."""
@@ -71,36 +100,21 @@ class DdnsService:
self,
request: Request,
hostname: str,
- myip: str | None,
- ipaddr: str | None,
- myipv6: str | None,
- ip6addr: str | None,
+ ip_params: UpdateIpParams,
) -> bool:
if not dns_backend_configured(self.settings):
return False
source_ip = client_ip(request, self.settings)
- source_fallback = source_ip if _is_global_ip(source_ip) else ""
- supplied_ip = any(value and value.strip() for value in (myip, ipaddr, myipv6, ip6addr))
- ipv4: str | None
- ipv6: str | None
- if not supplied_ip and not source_fallback:
- ipv4 = INITIAL_PLACEHOLDER_IPV4
- ipv6 = None
- update_ipv4 = True
- update_ipv6 = False
- detail = "initial placeholder dns registered"
- else:
- ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
- source_fallback,
- myip,
- ipaddr,
- myipv6,
- ip6addr,
- )
- detail = "initial dns registered"
+ plan = _initial_dns_plan(source_ip, *ip_params.initial_plan_args)
lock = self._lock_for(hostname)
with lock:
- planned = self.store.preview_update(hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6)
+ planned = self.store.preview_update(
+ hostname,
+ plan.ipv4,
+ plan.ipv6,
+ update_ipv4=plan.update_ipv4,
+ update_ipv6=plan.update_ipv6,
+ )
if planned.changed:
try:
publish_dns(planned, self.settings)
@@ -113,7 +127,12 @@ class DdnsService:
type(exc).__name__,
)
self.store.log_update_event(
- hostname, ipv4, ipv6, DNS_RECONCILE_FAILED, f"{type(exc).__name__}: {exc}", source_ip
+ hostname,
+ plan.ipv4,
+ plan.ipv6,
+ DNS_RECONCILE_FAILED,
+ f"{type(exc).__name__}: {exc}",
+ source_ip,
)
raise
except DnsProviderError as exc:
@@ -125,21 +144,23 @@ class DdnsService:
type(exc).__name__,
)
self.store.log_update_event(
- hostname, ipv4, ipv6, DNS_FAILED, f"{type(exc).__name__}: {exc}", source_ip
+ hostname,
+ plan.ipv4,
+ plan.ipv6,
+ DNS_FAILED,
+ f"{type(exc).__name__}: {exc}",
+ source_ip,
)
raise
result = self.store.commit_update_result(planned)
- self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, detail, source_ip)
+ self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, plan.detail, source_ip)
return True
def apply_update(
self,
request: Request,
hostname: str,
- myip: str | None,
- ipaddr: str | None,
- myipv6: str | None,
- ip6addr: str | None,
+ ip_params: UpdateIpParams,
) -> UpdateOutcome:
started_at = time.perf_counter()
is_success = False
@@ -150,10 +171,7 @@ class DdnsService:
try:
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
source_ip,
- myip,
- ipaddr,
- myipv6,
- ip6addr,
+ *ip_params.initial_plan_args,
)
lock = self._lock_for(hostname)
with lock:
@@ -248,13 +266,8 @@ class DdnsService:
def normalize_ip(value: str | None, version: int | None = None) -> str | None:
- if not value or not value.strip():
- return None
- try:
- ip = ipaddress.ip_address(value.strip())
- except ValueError:
- return None
- if version and ip.version != version:
+ ip = _optional_ip(value)
+ if ip is None or (version and ip.version != version):
return None
return str(ip)
@@ -280,6 +293,42 @@ def parse_update_ips(
return ipv4, ipv6, update_ipv4, update_ipv6
+def _initial_dns_plan(
+ source_ip: str,
+ myip: str | None,
+ ipaddr: str | None,
+ myipv6: str | None,
+ ip6addr: str | None,
+) -> InitialDnsPlan:
+ source_fallback = source_ip if _is_global_ip(source_ip) else ""
+ if not _has_supplied_ip(myip, ipaddr, myipv6, ip6addr) and not source_fallback:
+ return InitialDnsPlan(
+ ipv4=INITIAL_PLACEHOLDER_IPV4,
+ ipv6=None,
+ update_ipv4=True,
+ update_ipv6=False,
+ detail="initial placeholder dns registered",
+ )
+ ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
+ source_fallback,
+ myip,
+ ipaddr,
+ myipv6,
+ ip6addr,
+ )
+ return InitialDnsPlan(
+ ipv4=ipv4,
+ ipv6=ipv6,
+ update_ipv4=update_ipv4,
+ update_ipv6=update_ipv6,
+ detail="initial dns registered",
+ )
+
+
+def _has_supplied_ip(*values: str | None) -> bool:
+ return any(value and value.strip() for value in values)
+
+
def _is_global_ip(value: str | None) -> bool:
if not value:
return False
@@ -294,17 +343,31 @@ def _first_valid_ip(params: tuple[tuple[str, str | None], ...], version: int) ->
for name, value in params:
if value is None or not value.strip():
continue
- try:
- ip = ipaddress.ip_address(value.strip())
- except ValueError as exc:
- raise BadIpError(f"{name} must be a valid IPv{version} address") from exc
- if ip.version != version:
- raise BadIpError(f"{name} must be an IPv{version} address")
+ ip_value = _required_ip_param(name, value, version)
if selected is None:
- selected = str(ip)
+ selected = ip_value
return selected
+def _optional_ip(value: str | None) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
+ if not value or not value.strip():
+ return None
+ try:
+ return ipaddress.ip_address(value.strip())
+ except ValueError:
+ return None
+
+
+def _required_ip_param(name: str, value: str, version: int) -> str:
+ try:
+ ip = ipaddress.ip_address(value.strip())
+ except ValueError as exc:
+ raise BadIpError(f"{name} must be a valid IPv{version} address") from exc
+ if ip.version != version:
+ raise BadIpError(f"{name} must be an IPv{version} address")
+ return str(ip)
+
+
def normalize_hostname(value: str, settings: DdnsSettings, expand_suffix: bool = True) -> str:
hostname = value.strip().lower().rstrip(".")
suffix = settings.hostname_suffix.strip().lower().strip(".")
diff --git a/karldns/settings.py b/karldns/settings.py
index f4d3f6a..0f4aafd 100644
--- a/karldns/settings.py
+++ b/karldns/settings.py
@@ -6,6 +6,34 @@ from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field, field_validator
+_RESERVED_PUBLIC_PATHS = {
+ "/admin.css",
+ "/admin-tables.js",
+ "/api",
+ "/app.js",
+ "/clipboard.js",
+ "/docs",
+ "/favicon.ico",
+ "/healthz",
+ "/install.sh",
+ "/bootstrap-subset.css",
+ "/logo.png",
+ "/logo-nav.webp",
+ "/logo.webp",
+ "/m",
+ "/openapi.json",
+ "/privacy",
+ "/redoc",
+ "/robots.txt",
+ "/sitemap.xml",
+ "/site.webmanifest",
+ "/social-preview.png",
+ "/social-preview.webp",
+ "/theme.js",
+ "/uninstall.sh",
+ "/visitor-ip.js",
+}
+
class DdnsSettings(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -42,52 +70,25 @@ class DdnsSettings(BaseModel):
@field_validator("public_base_url")
@classmethod
- def validate_public_base_url(cls, value: str) -> str:
+ def validate_public_base_url(_cls, value: str) -> str:
if not value.startswith(("http://", "https://")):
raise ValueError("public_base_url must start with http:// or https://")
return value.rstrip("/")
@field_validator("trusted_hosts")
@classmethod
- def validate_trusted_hosts(cls, value: set[str]) -> set[str]:
+ def validate_trusted_hosts(_cls, value: set[str]) -> set[str]:
return {host.strip().lower() for host in value if host.strip()}
@field_validator("admin_path")
@classmethod
- def validate_admin_path(cls, value: str) -> str:
+ def validate_admin_path(_cls, value: str) -> str:
path = value.strip()
- if not path.startswith("/") or path == "/" or path.endswith("/"):
+ if not _has_valid_admin_path_shape(path):
raise ValueError("admin_path must start with / and must not be / or end with /")
- if not re.fullmatch(r"/[A-Za-z0-9._~/-]+", path):
+ if not _has_url_safe_path_chars(path):
raise ValueError("admin_path may only contain URL-safe path characters")
- reserved_paths = {
- "/admin.css",
- "/admin-tables.js",
- "/api",
- "/app.js",
- "/clipboard.js",
- "/docs",
- "/favicon.ico",
- "/healthz",
- "/install.sh",
- "/bootstrap-subset.css",
- "/logo.png",
- "/logo-nav.webp",
- "/logo.webp",
- "/m",
- "/openapi.json",
- "/privacy",
- "/redoc",
- "/robots.txt",
- "/sitemap.xml",
- "/site.webmanifest",
- "/social-preview.png",
- "/social-preview.webp",
- "/theme.js",
- "/uninstall.sh",
- "/visitor-ip.js",
- }
- if any(path == reserved or path.startswith(f"{reserved}/") for reserved in reserved_paths):
+ if _is_reserved_admin_path(path):
raise ValueError("admin_path must not conflict with a public route")
return path
@@ -107,19 +108,44 @@ class DdnsSettings(BaseModel):
validate_dns_provider_settings(self)
if not self.require_dns_provider:
return
- if len(self.admin_password) < 16:
- raise RuntimeError("DDNS_ADMIN_PASSWORD must be at least 16 characters when DDNS_REQUIRE_DNS_PROVIDER=1")
- if len(self.dashboard_cookie_secret or self.admin_password) < 32:
- raise RuntimeError(
- "DDNS_DASHBOARD_COOKIE_SECRET or DDNS_ADMIN_PASSWORD must be at least 32 characters for dashboard cookie signing when DDNS_REQUIRE_DNS_PROVIDER=1"
- )
- if not self.hostname_suffix:
- raise RuntimeError("DDNS_HOSTNAME_SUFFIX is required when DDNS_REQUIRE_DNS_PROVIDER=1")
- if not self.effective_trusted_hosts:
- raise RuntimeError("At least one trusted host is required for production")
+ _validate_launch_secrets(self.admin_password, self.dashboard_cookie_secret)
+ _validate_launch_hostname_suffix(self.hostname_suffix)
+ _validate_launch_trusted_hosts(self.effective_trusted_hosts)
@classmethod
- def from_env(cls) -> DdnsSettings:
+ def from_env(_cls) -> DdnsSettings:
from .config import settings_from_env
return settings_from_env()
+
+
+def _has_valid_admin_path_shape(path: str) -> bool:
+ return path.startswith("/") and path != "/" and not path.endswith("/")
+
+
+def _has_url_safe_path_chars(path: str) -> bool:
+ return re.fullmatch(r"/[A-Za-z0-9._~/-]+", path) is not None
+
+
+def _is_reserved_admin_path(path: str) -> bool:
+ return any(path == reserved or path.startswith(f"{reserved}/") for reserved in _RESERVED_PUBLIC_PATHS)
+
+
+def _validate_launch_secrets(admin_password: str, dashboard_cookie_secret: str) -> None:
+ if len(admin_password) < 16:
+ raise RuntimeError("DDNS_ADMIN_PASSWORD must be at least 16 characters when DDNS_REQUIRE_DNS_PROVIDER=1")
+ if len(dashboard_cookie_secret or admin_password) < 32:
+ raise RuntimeError(
+ "DDNS_DASHBOARD_COOKIE_SECRET or DDNS_ADMIN_PASSWORD must be at least 32 characters "
+ "for dashboard cookie signing when DDNS_REQUIRE_DNS_PROVIDER=1"
+ )
+
+
+def _validate_launch_hostname_suffix(hostname_suffix: str) -> None:
+ if not hostname_suffix:
+ raise RuntimeError("DDNS_HOSTNAME_SUFFIX is required when DDNS_REQUIRE_DNS_PROVIDER=1")
+
+
+def _validate_launch_trusted_hosts(effective_trusted_hosts: list[str]) -> None:
+ if not effective_trusted_hosts:
+ raise RuntimeError("At least one trusted host is required for production")
diff --git a/karldns/store/accounts.py b/karldns/store/accounts.py
index 0ababbd..fa6bb97 100644
--- a/karldns/store/accounts.py
+++ b/karldns/store/accounts.py
@@ -9,6 +9,7 @@ from .types import AccountRow, account_row
class AccountsMixin(StoreConnectionMixin):
def create_account(self, hostname: str, username: str | None = None, *, accepted: bool = True) -> AccountRow:
+ _ = username
now = datetime.now(UTC).isoformat()
update_slug = new_bearer_secret()
management_slug = new_bearer_secret()
@@ -27,6 +28,7 @@ class AccountsMixin(StoreConnectionMixin):
"update_slug": update_slug,
"management_slug": management_slug,
"accepted_at": accepted_at,
+ "disabled": 0,
}
def list_accounts(self, limit: int = 100, offset: int = 0) -> list[AccountRow]:
@@ -34,7 +36,8 @@ class AccountsMixin(StoreConnectionMixin):
rows = conn.execute(
"""
SELECT accounts.hostname, accounts.update_slug,
- accounts.created_at, accounts.accepted_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
+ accounts.created_at, accounts.accepted_at, accounts.disabled,
+ records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
ORDER BY accounts.hostname
@@ -54,7 +57,8 @@ class AccountsMixin(StoreConnectionMixin):
account = conn.execute(
"""
SELECT accounts.hostname, accounts.update_slug, accounts.dashboard_password_hash,
- accounts.created_at, accounts.accepted_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
+ accounts.created_at, accounts.accepted_at, accounts.disabled,
+ records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.hostname = ?
@@ -79,7 +83,8 @@ class AccountsMixin(StoreConnectionMixin):
account = conn.execute(
"""
SELECT accounts.hostname, accounts.update_slug, accounts.dashboard_password_hash,
- accounts.created_at, accounts.accepted_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
+ accounts.created_at, accounts.accepted_at, accounts.disabled,
+ records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.management_hash = ?
diff --git a/karldns/store/metrics.py b/karldns/store/metrics.py
index e321baf..3022529 100644
--- a/karldns/store/metrics.py
+++ b/karldns/store/metrics.py
@@ -5,6 +5,29 @@ from datetime import UTC, datetime
from .base import StoreConnectionMixin
+def _update_metric_increments(
+ latency_ms: float,
+ *,
+ is_success: bool,
+ is_dns_failure: bool,
+ is_reconcile_failure: bool,
+ is_bad_ip: bool,
+) -> dict[str, float]:
+ increments: dict[str, float] = {
+ "update_requests_total": 1,
+ "update_latency_ms_total": latency_ms,
+ "update_latency_samples_total": 1,
+ }
+ optional_increments = {
+ "update_success_total": is_success,
+ "dns_failures_total": is_dns_failure,
+ "dns_reconcile_failures_total": is_reconcile_failure,
+ "bad_ip_total": is_bad_ip,
+ }
+ increments.update({name: 1 for name, enabled in optional_increments.items() if enabled})
+ return increments
+
+
class MetricsMixin(StoreConnectionMixin):
def increment_metric(self, name: str, amount: float = 1.0) -> None:
now = datetime.now(UTC).isoformat()
@@ -48,19 +71,13 @@ class MetricsMixin(StoreConnectionMixin):
is_reconcile_failure: bool = False,
is_bad_ip: bool = False,
) -> None:
- increments = {
- "update_requests_total": 1,
- "update_latency_ms_total": latency_ms,
- "update_latency_samples_total": 1,
- }
- if is_success:
- increments["update_success_total"] = 1
- if is_dns_failure:
- increments["dns_failures_total"] = 1
- if is_reconcile_failure:
- increments["dns_reconcile_failures_total"] = 1
- if is_bad_ip:
- increments["bad_ip_total"] = 1
+ increments = _update_metric_increments(
+ latency_ms,
+ is_success=is_success,
+ is_dns_failure=is_dns_failure,
+ is_reconcile_failure=is_reconcile_failure,
+ is_bad_ip=is_bad_ip,
+ )
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
diff --git a/karldns/store/rate_limit.py b/karldns/store/rate_limit.py
index 4b5dd54..b113c3b 100644
--- a/karldns/store/rate_limit.py
+++ b/karldns/store/rate_limit.py
@@ -199,9 +199,11 @@ class RateLimitCleanupMixin(StoreConnectionMixin):
[
(
row["hostname"],
- CLEANUP_PENDING_HOSTNAME_DELETED
- if row["cleanup_reason"] == "pending"
- else CLEANUP_UNUSED_HOSTNAME_DELETED,
+ (
+ CLEANUP_PENDING_HOSTNAME_DELETED
+ if row["cleanup_reason"] == "pending"
+ else CLEANUP_UNUSED_HOSTNAME_DELETED
+ ),
"cleanup deleted abandoned hostname",
"127.0.0.1",
datetime.now(UTC).isoformat(),
diff --git a/karldns/store/records.py b/karldns/store/records.py
index aeedfa3..240356f 100644
--- a/karldns/store/records.py
+++ b/karldns/store/records.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import sqlite3
+from dataclasses import dataclass
from datetime import UTC, datetime
from ..models import UpdateResult
@@ -8,6 +9,60 @@ from .base import StoreConnectionMixin
from .types import RecordRow, UpdateEventRow, record_row, update_event_row
+@dataclass(frozen=True, slots=True)
+class UpdateEventInput:
+ hostname: str
+ ipv4: str | None
+ ipv6: str | None
+ status: str
+ detail: str
+ source_ip: str
+
+
+@dataclass(frozen=True, slots=True)
+class RecordUpdateInput:
+ hostname: str
+ ipv4: str | None
+ ipv6: str | None
+ update_ipv4: bool = True
+ update_ipv6: bool = True
+
+
+def _update_event_input(event: UpdateEventInput | str, legacy_values: tuple[object, ...]) -> UpdateEventInput:
+ if isinstance(event, UpdateEventInput):
+ return event
+ if len(legacy_values) != 5:
+ raise TypeError("legacy update event values must be ipv4, ipv6, status, detail, source_ip")
+ ipv4, ipv6, status, detail, source_ip = legacy_values
+ return UpdateEventInput(
+ hostname=event,
+ ipv4=str(ipv4) if ipv4 is not None else None,
+ ipv6=str(ipv6) if ipv6 is not None else None,
+ status=str(status),
+ detail=str(detail),
+ source_ip=str(source_ip),
+ )
+
+
+def _planned_record_update(
+ update: RecordUpdateInput,
+ *,
+ current_ipv4: str | None,
+ current_ipv6: str | None,
+ existing: bool,
+) -> UpdateResult:
+ effective_ipv4 = update.ipv4 if update.update_ipv4 else current_ipv4
+ effective_ipv6 = update.ipv6 if update.update_ipv6 else current_ipv6
+ return UpdateResult(
+ hostname=update.hostname,
+ ipv4=effective_ipv4,
+ ipv6=effective_ipv6,
+ changed=not existing or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6,
+ update_ipv4=update.update_ipv4,
+ update_ipv6=update.update_ipv6,
+ )
+
+
class RecordsEventsMixin(StoreConnectionMixin):
def upsert(
self,
@@ -18,13 +73,17 @@ class RecordsEventsMixin(StoreConnectionMixin):
update_ipv6: bool = True,
) -> UpdateResult:
now = datetime.now(UTC).isoformat()
+ update = RecordUpdateInput(hostname, ipv4, ipv6, update_ipv4, update_ipv6)
with self.connect() as conn:
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
current_ipv4 = existing["ipv4"] if existing else None
current_ipv6 = existing["ipv6"] if existing else None
- effective_ipv4 = ipv4 if update_ipv4 else current_ipv4
- effective_ipv6 = ipv6 if update_ipv6 else current_ipv6
- changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6
+ result = _planned_record_update(
+ update,
+ current_ipv4=current_ipv4,
+ current_ipv6=current_ipv6,
+ existing=existing is not None,
+ )
conn.execute(
"""
INSERT INTO records(hostname, ipv4, ipv6, updated_at)
@@ -34,16 +93,9 @@ class RecordsEventsMixin(StoreConnectionMixin):
ipv6=excluded.ipv6,
updated_at=excluded.updated_at
""",
- (hostname, effective_ipv4, effective_ipv6, now),
+ (result.hostname, result.ipv4, result.ipv6, now),
)
- return UpdateResult(
- hostname=hostname,
- ipv4=effective_ipv4,
- ipv6=effective_ipv6,
- changed=changed,
- update_ipv4=update_ipv4,
- update_ipv6=update_ipv6,
- )
+ return result
def commit_update_result(self, result: UpdateResult) -> UpdateResult:
now = datetime.now(UTC).isoformat()
@@ -69,20 +121,16 @@ class RecordsEventsMixin(StoreConnectionMixin):
update_ipv4: bool = True,
update_ipv6: bool = True,
) -> UpdateResult:
+ update = RecordUpdateInput(hostname, ipv4, ipv6, update_ipv4, update_ipv6)
with self.connect() as conn:
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
current_ipv4 = existing["ipv4"] if existing else None
current_ipv6 = existing["ipv6"] if existing else None
- effective_ipv4 = ipv4 if update_ipv4 else current_ipv4
- effective_ipv6 = ipv6 if update_ipv6 else current_ipv6
- changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6
- return UpdateResult(
- hostname=hostname,
- ipv4=effective_ipv4,
- ipv6=effective_ipv6,
- changed=changed,
- update_ipv4=update_ipv4,
- update_ipv6=update_ipv6,
+ return _planned_record_update(
+ update,
+ current_ipv4=current_ipv4,
+ current_ipv6=current_ipv6,
+ existing=existing is not None,
)
def list_records(self) -> list[RecordRow]:
@@ -106,33 +154,27 @@ class RecordsEventsMixin(StoreConnectionMixin):
row = conn.execute("SELECT COUNT(*) FROM records").fetchone()
return int(row[0])
- def log_update_event(
- self,
- hostname: str,
- ipv4: str | None,
- ipv6: str | None,
- status: str,
- detail: str,
- source_ip: str,
- ) -> None:
+ def log_update_event(self, event: UpdateEventInput | str, *legacy_values: object) -> None:
+ event = _update_event_input(event, legacy_values)
with self.connect() as conn:
conn.execute(
"""
INSERT INTO update_events(hostname, ipv4, ipv6, status, detail, source_ip, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
- (hostname, ipv4, ipv6, status, detail[:500], source_ip, datetime.now(UTC).isoformat()),
+ (
+ event.hostname,
+ event.ipv4,
+ event.ipv6,
+ event.status,
+ event.detail[:500],
+ event.source_ip,
+ datetime.now(UTC).isoformat(),
+ ),
)
- def log_update_rollup(
- self,
- hostname: str,
- ipv4: str | None,
- ipv6: str | None,
- status: str,
- detail: str,
- source_ip: str,
- ) -> None:
+ def log_update_rollup(self, event: UpdateEventInput | str, *legacy_values: object) -> None:
+ event = _update_event_input(event, legacy_values)
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.execute(
@@ -145,7 +187,16 @@ class RecordsEventsMixin(StoreConnectionMixin):
last_seen_at=excluded.last_seen_at,
count=count + 1
""",
- (hostname, ipv4 or "", ipv6 or "", status, detail[:500], source_ip, now, now),
+ (
+ event.hostname,
+ event.ipv4 or "",
+ event.ipv6 or "",
+ event.status,
+ event.detail[:500],
+ event.source_ip,
+ now,
+ now,
+ ),
)
def list_update_events(self, limit: int = 100, offset: int = 0) -> list[UpdateEventRow]:
diff --git a/karldns/store/schema.py b/karldns/store/schema.py
index dc96a1b..1a21036 100644
--- a/karldns/store/schema.py
+++ b/karldns/store/schema.py
@@ -186,7 +186,10 @@ def _migrate_accounts_schema(conn: sqlite3.Connection) -> None:
)
""")
conn.execute("""
- INSERT INTO accounts_new(hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled)
+ INSERT INTO accounts_new(
+ hostname, update_slug, management_hash, dashboard_password_hash,
+ created_at, accepted_at, disabled
+ )
SELECT hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled
FROM accounts
""")
diff --git a/karldns/store/types.py b/karldns/store/types.py
index a9f01ce..5661bf1 100644
--- a/karldns/store/types.py
+++ b/karldns/store/types.py
@@ -1,22 +1,22 @@
from __future__ import annotations
-from typing import Any, TypedDict, cast
+from typing import Any, NotRequired, TypedDict, cast
-class AccountRow(TypedDict, total=False):
+class AccountRow(TypedDict):
hostname: str
update_slug: str
- management_slug: str
- dashboard_password_hash: str | None
- created_at: str
accepted_at: str | None
disabled: int
- ipv4: str | None
- ipv6: str | None
- updated_at: str | None
+ management_slug: NotRequired[str]
+ dashboard_password_hash: NotRequired[str | None]
+ created_at: NotRequired[str]
+ ipv4: NotRequired[str | None]
+ ipv6: NotRequired[str | None]
+ updated_at: NotRequired[str | None]
-class UpdateEventRow(TypedDict, total=False):
+class UpdateEventRow(TypedDict):
hostname: str
ipv4: str | None
ipv6: str | None
@@ -33,9 +33,9 @@ class CleanupRunRow(TypedDict):
class ScheduledTaskRow(TypedDict, total=False):
- name: str
- interval_seconds: int
- next_run_at: str
+ name: NotRequired[str]
+ interval_seconds: NotRequired[int]
+ next_run_at: NotRequired[str]
lease_owner: str | None
lease_expires_at: str | None
last_started_at: str | None
diff --git a/karldns/web/components.py b/karldns/web/components.py
index c1b99e5..f84b493 100644
--- a/karldns/web/components.py
+++ b/karldns/web/components.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+from dataclasses import dataclass
from ipaddress import ip_address
from .html_utils import SafeHtml, attr, escape, trusted
@@ -28,32 +29,77 @@ def _event_label(status: object) -> str:
return _EVENT_LABELS.get(value, value.replace("_", " "))
+@dataclass(frozen=True, slots=True)
+class _CopyRowParts:
+ label: str
+ value: str
+ safe_label: str
+ safe_value: str
+ attr_label: str
+ attr_value: str
+ is_secret: bool
+ should_mask: bool
+ sensitivity_badge: str
+
+ @property
+ def row_class(self) -> str:
+ return "copy-field copy-field-secret" if self.is_secret else "copy-field copy-field-public"
+
+ @property
+ def value_field(self) -> str:
+ if self.should_mask:
+ return (
+ f'"
+ )
+ return f'{self.safe_value}'
+
+ @property
+ def reveal_button(self) -> str:
+ if not self.should_mask:
+ return ""
+ return (
+ f''
+ )
+
+ @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'{escape(sensitivity)}'
+
+
def _copy_row(label: str, value: str, sensitivity: str | None = None, *, mask_secret: bool = True) -> SafeHtml:
- safe_label = escape(label)
- safe_value = escape(value)
- attr_label = attr(label)
- attr_value = attr(value)
- is_secret = bool(sensitivity and sensitivity.lower() == "secret")
- should_mask = is_secret and mask_secret
- value_field = (
- f''
- if should_mask
- else f'{safe_value}'
- )
- reveal_button = (
- f''
- if should_mask
- else ""
- )
- row_class = "copy-field copy-field-secret" if is_secret else "copy-field copy-field-public"
+ parts = _CopyRowParts.from_values(label, value, sensitivity, mask_secret=mask_secret)
return trusted(f"""
-