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""" -
+
- {safe_label} - {value_field} - {reveal_button} + {parts.safe_label} + {parts.sensitivity_badge} + {parts.value_field} + {parts.reveal_button}
-
@@ -66,16 +112,22 @@ def _copy_button(label: str, value: str, *, style: str = "outline-primary") -> S safe_style = attr(style) return trusted( f'' + f'aria-label="Copy {safe_label}">{_svg_icon("clipboard")}' + f'{safe_label}{_svg_icon("check", "check-icon")}' ) def _copy_icon_button(label: str, value: str) -> SafeHtml: safe_label = escape(label) safe_value = attr(value) + button_attrs = ( + f'type="button" class="copy-icon-button" data-copy="{safe_value}" ' + f'aria-label="Copy {safe_label}" title="Copy"' + ) return trusted( - f'' + f"' ) @@ -83,9 +135,14 @@ def _copy_menu(label: str, items: list[tuple[str, str]]) -> SafeHtml: safe_label = escape(label) buttons = [] for item_label, item_value in items: + button_attrs = ( + f'type="button" class="copy-menu-item" data-copy="{attr(item_value)}" ' + f'aria-label="Copy {attr(item_label)}"' + ) buttons.append( - f'' + f"' ) return trusted(f"""
@@ -106,7 +163,7 @@ def _code_window( code_lines = "\n".join(f"{escape(line)}" for line in commands.splitlines()) copy_value = copy_value if copy_value is not None else _code_copy_value(commands) return trusted(f""" -
+
{_copy_icon_button(copy_label, copy_value)} {code_lines}
@@ -115,10 +172,14 @@ def _code_window( def _code_copy_value(commands: str) -> str: lines = commands.splitlines() - prompt_lines = [line[2:] for line in lines if line.startswith("$ ")] + prompt_lines = [_code_copy_line(line) for line in lines if line.startswith("$ ")] if prompt_lines: return "\n".join(prompt_lines) - return "\n".join(line[2:] if line.startswith("$ ") else line for line in lines) + return "\n".join(_code_copy_line(line) for line in lines) + + +def _code_copy_line(line: str) -> str: + return line[2:] if line.startswith("$ ") else line def _example_update_ips(request_ip: str | None = None) -> tuple[str, str]: @@ -164,12 +225,17 @@ def _known_request_ip(request_ip: str | None = None) -> tuple[int | None, str]: def _manual_update_panel(commands: str) -> SafeHtml: return trusted(f""" -
-
+
+

Manual updates

-

FRITZ!Box fills in the IP placeholders automatically. Scripts can use curl. If no IP is sent, KarlDNS uses the request IP.

+

+ FRITZ!Box fills in the IP placeholders automatically. Scripts can use curl. + If no IP is sent, KarlDNS uses the request IP. +

+
+
+ {_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
- {_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
""") @@ -194,10 +260,14 @@ def _secret_notice(*, include_registration: bool = False) -> SafeHtml: "Update URL, register URL, and dashboard link" if include_registration else "Update URL and dashboard link" ) return trusted(f""" -
- Public: Hostname and CNAME target can be shared. - Secret: {escape(secret_items)} can update or manage this hostname. - Secrets are hidden on screen. Copy still copies the real value. +
+ Public: + Hostname and CNAME target can be shared. + Secret: + + {escape(secret_items)} can update or manage this hostname. Secrets are hidden on screen. + Copy still copies the real value. +
""") @@ -206,7 +276,8 @@ def _message_band(message: str | None) -> SafeHtml: if not message: return trusted("") return trusted(f""" -
+
{escape(message)}
""") diff --git a/karldns/web/icons.py b/karldns/web/icons.py index bc9ee9c..ce9c6b9 100644 --- a/karldns/web/icons.py +++ b/karldns/web/icons.py @@ -22,7 +22,8 @@ def _svg_icon(name: str, css_class: str = "copy-icon") -> SafeHtml: safe_class = attr(f"lucide-icon {css_class}") return trusted( f'' + f'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" ' + f'stroke-linejoin="round">{path}' ) diff --git a/karldns/web/layout.py b/karldns/web/layout.py index 9fc1d84..0c8b533 100644 --- a/karldns/web/layout.py +++ b/karldns/web/layout.py @@ -1,10 +1,27 @@ from __future__ import annotations import json +from dataclasses import dataclass, field from .html_utils import SafeHtml, attr, escape, trusted from .icons import _svg_icon +DEFAULT_DESCRIPTION = ( + "KarlDNS is a small self-hosted Dynamic DNS service for FRITZ!Box routers, " "Linux cron jobs, and home networks." +) + + +@dataclass(frozen=True) +class PageOptions: + extra_styles: SafeHtml = field(default_factory=lambda: trusted("")) + extra_scripts: SafeHtml = field(default_factory=lambda: trusted("")) + description: str = DEFAULT_DESCRIPTION + preload_logo: bool = False + canonical_url: str | None = None + image_url: str | None = None + image_alt: str | None = None + structured_data: dict[str, object] | None = None + def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtml: account_html = "" @@ -22,8 +39,11 @@ def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtm