initial commit

This commit is contained in:
2026-08-16 21:18:45 -05:00
commit 1e05a01bcf
122 changed files with 29178 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
// Package webui embeds the HTML templates and static assets.
//
// It lives at the repository root next to the files it embeds, because Go's
// embed directive cannot reach outside its own directory. The HTTP handlers
// that consume these assets live in internal/web.
//
// Everything the browser needs — Bootstrap, Bootstrap Icons and their web
// font, and Chart.js — is vendored here rather than loaded from a CDN, so the
// management interface works on a network with no Internet access. That is not
// a hypothetical: a DNS server whose own UI needs working DNS to render is
// unusable in exactly the situation an administrator most needs it.
package webui
import (
"embed"
"io/fs"
)
//go:embed templates
var templatesFS embed.FS
//go:embed static
var staticFS embed.FS
// Templates returns the template tree rooted at the templates directory.
func Templates() fs.FS {
sub, err := fs.Sub(templatesFS, "templates")
if err != nil {
// Unreachable: the directory is embedded at build time.
panic("webui: templates directory is missing from the binary: " + err.Error())
}
return sub
}
// Static returns the static asset tree rooted at the static directory.
func Static() fs.FS {
sub, err := fs.Sub(staticFS, "static")
if err != nil {
panic("webui: static directory is missing from the binary: " + err.Error())
}
return sub
}
+361
View File
@@ -0,0 +1,361 @@
/* vibedns management interface
*
* A thin layer on top of Bootstrap 5: an app shell with a fixed sidebar,
* a small set of component styles, and colour tokens that follow Bootstrap's
* own light/dark switch so the whole UI flips with one attribute.
*/
:root {
--app-sidebar-width: 248px;
--app-navbar-height: 56px;
--app-brand: #1b6ec2;
--app-brand-dark: #14508c;
--app-sidebar-bg: #ffffff;
--app-sidebar-border: var(--bs-border-color);
--app-sidebar-link: #495057;
--app-sidebar-link-hover-bg: #f1f3f5;
--app-sidebar-active-bg: #e7f1fb;
--app-sidebar-active-color: var(--app-brand-dark);
--app-body-bg: #f6f8fa;
--app-card-shadow: 0 1px 2px rgba(16, 24, 40, .06), 0 1px 3px rgba(16, 24, 40, .08);
}
[data-bs-theme="dark"] {
--app-brand: #4c9be8;
--app-brand-dark: #7cb8f0;
--app-sidebar-bg: #17191d;
--app-sidebar-border: #2b2f36;
--app-sidebar-link: #adb5bd;
--app-sidebar-link-hover-bg: #22252b;
--app-sidebar-active-bg: #1d2b3a;
--app-sidebar-active-color: #8fc3f5;
--app-body-bg: #101214;
--app-card-shadow: 0 1px 2px rgba(0, 0, 0, .4);
}
body {
background-color: var(--app-body-bg);
font-size: .9375rem;
}
.skip-link {
position: absolute;
top: .5rem;
left: .5rem;
z-index: 2000;
background: var(--bs-body-bg);
padding: .5rem .75rem;
border-radius: .375rem;
}
/* --- Shell ------------------------------------------------------------ */
.app-navbar {
background: linear-gradient(90deg, var(--app-brand-dark), var(--app-brand));
min-height: var(--app-navbar-height);
box-shadow: 0 1px 3px rgba(16, 24, 40, .15);
z-index: 1030;
}
.app-navbar .navbar-brand {
color: #fff;
font-weight: 600;
letter-spacing: .01em;
}
.app-navbar .navbar-brand:hover { color: #fff; }
.status-dot {
width: .55rem;
height: .55rem;
border-radius: 50%;
background: #adb5bd;
display: inline-block;
}
.status-dot.status-up {
background: #2fb344;
box-shadow: 0 0 0 3px rgba(47, 179, 68, .25);
}
.status-dot.status-down {
background: #d63939;
box-shadow: 0 0 0 3px rgba(214, 57, 57, .25);
}
.app-shell { display: flex; align-items: flex-start; }
.app-sidebar {
width: var(--app-sidebar-width);
flex: 0 0 var(--app-sidebar-width);
position: sticky;
top: var(--app-navbar-height);
height: calc(100vh - var(--app-navbar-height));
overflow-y: auto;
background: var(--app-sidebar-bg);
border-right: 1px solid var(--app-sidebar-border);
}
.app-main { flex: 1 1 auto; min-width: 0; }
.app-offcanvas { width: var(--app-sidebar-width); }
.sidebar-nav { padding: .75rem .5rem 2rem; }
.sidebar-heading {
font-size: .6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--bs-secondary-color);
padding: 1rem .75rem .35rem;
}
.sidebar-link {
display: flex;
align-items: center;
gap: .625rem;
padding: .5rem .75rem;
margin-bottom: .0625rem;
border-radius: .375rem;
color: var(--app-sidebar-link);
text-decoration: none;
font-weight: 500;
transition: background-color .12s ease, color .12s ease;
}
.sidebar-link i { font-size: 1rem; width: 1.25rem; text-align: center; flex: none; }
.sidebar-link:hover {
background: var(--app-sidebar-link-hover-bg);
color: var(--bs-body-color);
}
.sidebar-link.active {
background: var(--app-sidebar-active-bg);
color: var(--app-sidebar-active-color);
font-weight: 600;
}
/* --- Typography and cards --------------------------------------------- */
.page-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: .125rem;
}
.card {
border-color: var(--bs-border-color);
box-shadow: var(--app-card-shadow);
}
.card-header {
background: transparent;
border-bottom-color: var(--bs-border-color);
font-weight: 600;
padding-block: .75rem;
}
.stat-card .card-body { padding: 1rem 1.125rem; }
.stat-label {
font-size: .75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--bs-secondary-color);
}
.stat-value {
font-size: 1.75rem;
font-weight: 600;
line-height: 1.2;
margin-top: .25rem;
font-variant-numeric: tabular-nums;
}
.stat-sub { font-size: .8125rem; color: var(--bs-secondary-color); margin-top: .125rem; }
.stat-icon { font-size: 1.125rem; color: var(--bs-secondary-color); opacity: .75; }
/* --- Tables ------------------------------------------------------------ */
.table { --bs-table-bg: transparent; margin-bottom: 0; }
.table > thead > tr > th {
font-size: .75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--bs-secondary-color);
border-bottom-width: 1px;
white-space: nowrap;
}
.table > tbody > tr > td { vertical-align: middle; }
.table-hover > tbody > tr:hover > * { --bs-table-accent-bg: var(--app-sidebar-link-hover-bg); }
.table-compact > :not(caption) > * > * { padding: .5rem .625rem; }
.mono {
font-family: var(--bs-font-monospace);
font-size: .8125rem;
}
.truncate-cell {
max-width: 26rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-actions { white-space: nowrap; text-align: right; }
.row-actions .btn { --bs-btn-padding-y: .1875rem; --bs-btn-padding-x: .4375rem; }
/* Disabled rows read as inactive without disappearing. */
tr.is-disabled > td:not(.row-actions) { opacity: .55; }
/* --- Record type chips ------------------------------------------------- */
.type-chip {
display: inline-block;
min-width: 3.75rem;
text-align: center;
padding: .1875rem .4375rem;
border-radius: .25rem;
font-size: .75rem;
font-weight: 600;
font-family: var(--bs-font-monospace);
border: 1px solid transparent;
}
.type-addr { background: #e7f1fb; color: #14508c; border-color: #c9e0f7; }
.type-alias { background: #f3e8fd; color: #6b21a8; border-color: #e4d0fb; }
.type-service { background: #e6f7f1; color: #0f6b52; border-color: #c5ecdf; }
.type-auth { background: #fef3e2; color: #92500e; border-color: #fbe0b8; }
.type-text { background: #eef0f2; color: #41474d; border-color: #dde1e5; }
.type-sec { background: #fde8ec; color: #9b1c33; border-color: #f8ccd6; }
.type-ptr { background: #e8f4fd; color: #0b5d7a; border-color: #cbe6f6; }
.type-other { background: #eef0f2; color: #41474d; border-color: #dde1e5; }
[data-bs-theme="dark"] .type-addr { background: #12283d; color: #8fc3f5; border-color: #1d3d5c; }
[data-bs-theme="dark"] .type-alias { background: #2a1a3d; color: #cba6f7; border-color: #3d2757; }
[data-bs-theme="dark"] .type-service { background: #12302a; color: #7ad4b3; border-color: #1c4a3f; }
[data-bs-theme="dark"] .type-auth { background: #362508; color: #f0c674; border-color: #4d360f; }
[data-bs-theme="dark"] .type-text { background: #22252b; color: #adb5bd; border-color: #32363d; }
[data-bs-theme="dark"] .type-sec { background: #3a151f; color: #f2a1b2; border-color: #55202e; }
[data-bs-theme="dark"] .type-ptr { background: #102b36; color: #86cfe8; border-color: #1a4252; }
[data-bs-theme="dark"] .type-other { background: #22252b; color: #adb5bd; border-color: #32363d; }
/* --- Empty states ------------------------------------------------------ */
.empty-state { color: var(--bs-secondary-color); }
.empty-icon {
font-size: 2.75rem;
color: var(--bs-secondary-color);
opacity: .45;
}
/* --- Forms ------------------------------------------------------------- */
.form-label { font-weight: 500; margin-bottom: .3125rem; }
.form-text { font-size: .8125rem; }
.settings-nav .nav-link {
--bs-nav-pills-border-radius: .375rem;
padding: .375rem .75rem;
font-size: .875rem;
font-weight: 500;
color: var(--bs-body-color);
}
.settings-nav .nav-link:not(.active):hover { background: var(--app-sidebar-link-hover-bg); }
.form-section + .form-section { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--bs-border-color); }
.form-section-title { font-size: .9375rem; font-weight: 600; margin-bottom: .875rem; }
textarea.list-input {
font-family: var(--bs-font-monospace);
font-size: .8125rem;
min-height: 7.5rem;
}
/* Sticky action bar for long settings forms. */
.form-actions {
position: sticky;
bottom: 0;
background: var(--bs-body-bg);
border-top: 1px solid var(--bs-border-color);
padding: .875rem 1.125rem;
margin: 1.5rem -1.125rem -1.125rem;
border-radius: 0 0 var(--bs-card-border-radius) var(--bs-card-border-radius);
}
/* --- Charts ------------------------------------------------------------ */
.chart-wrap { position: relative; height: 260px; }
.chart-wrap-sm { position: relative; height: 200px; }
/* --- Misc -------------------------------------------------------------- */
.bulk-bar {
display: none;
align-items: center;
gap: .75rem;
padding: .625rem .875rem;
background: var(--app-sidebar-active-bg);
border: 1px solid var(--bs-border-color);
border-radius: .375rem;
margin-bottom: .75rem;
}
.bulk-bar.is-visible { display: flex; }
.token-secret {
font-family: var(--bs-font-monospace);
font-size: .875rem;
word-break: break-all;
background: var(--bs-tertiary-bg);
padding: .625rem .75rem;
border-radius: .375rem;
border: 1px solid var(--bs-border-color);
}
.answer-block {
font-family: var(--bs-font-monospace);
font-size: .8125rem;
white-space: pre-wrap;
word-break: break-all;
margin-bottom: 0;
}
.progress-thin { height: .375rem; }
.list-meta { display: flex; flex-wrap: wrap; gap: 1.25rem; }
.list-meta > div { min-width: 7rem; }
.list-meta dt {
font-size: .75rem;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--bs-secondary-color);
font-weight: 600;
}
.list-meta dd { font-size: 1.125rem; font-weight: 600; margin-bottom: 0; }
@media (max-width: 991.98px) {
.app-sidebar { display: none; }
}
@media print {
.app-sidebar, .app-navbar, .toast-container, .row-actions { display: none !important; }
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="VibeDNS">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#2a86d8"/>
<stop offset="100%" stop-color="#14508c"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="13" fill="url(#g)"/>
<g fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round">
<circle cx="32" cy="32" r="16"/>
<ellipse cx="32" cy="32" rx="7" ry="16"/>
<path d="M16.6 27h30.8M16.6 37h30.8"/>
</g>
<circle cx="32" cy="32" r="4.2" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 604 B

+540
View File
@@ -0,0 +1,540 @@
/* vibedns management interface behaviour.
*
* Deliberately small and dependency-free beyond Bootstrap's own bundle: theme
* persistence, toast display, confirmation dialogs, bulk selection, the
* record-type editor, and a couple of small conveniences. There is no build
* step and nothing is fetched from the network.
*/
(function () {
'use strict';
// --- Theme ------------------------------------------------------------
var THEME_KEY = 'vibedns.theme';
function storedTheme() {
try { return localStorage.getItem(THEME_KEY); } catch (e) { return null; }
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
try { localStorage.setItem(THEME_KEY, theme); } catch (e) { /* private mode */ }
}
function initTheme() {
var saved = storedTheme();
if (!saved) {
saved = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light';
}
document.documentElement.setAttribute('data-bs-theme', saved);
var toggle = document.getElementById('themeToggle');
if (toggle) {
toggle.addEventListener('click', function () {
var current = document.documentElement.getAttribute('data-bs-theme');
applyTheme(current === 'dark' ? 'light' : 'dark');
});
}
}
// Apply before first paint to avoid a flash of the wrong theme.
(function () {
var saved = storedTheme();
if (saved) { document.documentElement.setAttribute('data-bs-theme', saved); }
})();
// --- Toasts -----------------------------------------------------------
function initToasts() {
document.querySelectorAll('#toastContainer .toast').forEach(function (el) {
var autohide = el.getAttribute('data-autohide') !== 'false';
new bootstrap.Toast(el, { autohide: autohide, delay: 6000 }).show();
});
}
/** Shows a transient message without a page reload. */
window.vibednsToast = function (level, message) {
// Successful actions already update the page, so avoid distracting green
// confirmation boxes. Warnings and errors remain visible.
if (level === 'success') { return; }
var container = document.getElementById('toastContainer');
if (!container) { return; }
var el = document.createElement('div');
el.className = 'toast align-items-center border-0 text-bg-' + level;
el.setAttribute('role', 'alert');
var body = document.createElement('div');
body.className = 'd-flex';
var text = document.createElement('div');
text.className = 'toast-body';
text.textContent = message;
var close = document.createElement('button');
close.type = 'button';
close.className = 'btn-close btn-close-white me-2 m-auto';
close.setAttribute('data-bs-dismiss', 'toast');
close.setAttribute('aria-label', 'Close');
body.appendChild(text);
body.appendChild(close);
el.appendChild(body);
container.appendChild(el);
new bootstrap.Toast(el, { delay: 5000 }).show();
el.addEventListener('hidden.bs.toast', function () { el.remove(); });
};
// --- Confirmation dialogs --------------------------------------------
function initConfirmations() {
var modalEl = document.getElementById('confirmModal');
if (!modalEl) { return; }
var modal = new bootstrap.Modal(modalEl);
var bodyEl = document.getElementById('confirmModalBody');
var acceptEl = document.getElementById('confirmModalAccept');
var pendingForm = null;
var pendingButton = null;
function ask(form, button, message) {
pendingForm = form;
pendingButton = button;
bodyEl.textContent = message || 'This action cannot be undone.';
acceptEl.textContent = (form && form.dataset.confirmLabel) || 'Confirm';
modal.show();
}
// A whole form marked js-confirm (single-action buttons such as Delete).
document.addEventListener('submit', function (ev) {
var form = ev.target;
if (!form.classList || !form.classList.contains('js-confirm')) { return; }
if (form.dataset.confirmed === 'true') { return; }
ev.preventDefault();
ask(form, null, form.dataset.confirm);
});
// An individual submit button inside a multi-action form (bulk toolbars),
// where only one of the buttons is destructive.
document.addEventListener('click', function (ev) {
var btn = ev.target.closest('button.js-confirm-bulk');
if (!btn || btn.dataset.confirmed === 'true') { return; }
var form = btn.form;
if (!form) { return; }
ev.preventDefault();
ask(form, btn, btn.dataset.confirm);
});
acceptEl.addEventListener('click', function () {
if (!pendingForm) { return; }
modal.hide();
if (pendingButton) {
pendingButton.dataset.confirmed = 'true';
// requestSubmit keeps the button's name/value in the submission, which
// is how the server knows which bulk action was chosen.
pendingForm.requestSubmit(pendingButton);
} else {
pendingForm.dataset.confirmed = 'true';
pendingForm.submit();
}
pendingForm = null;
pendingButton = null;
});
modalEl.addEventListener('hidden.bs.modal', function () {
pendingForm = null;
pendingButton = null;
});
}
// --- Filter forms -----------------------------------------------------
/** Submits a filter form shortly after the user stops typing. */
function initAutoFilters() {
document.querySelectorAll('form[data-autosubmit]').forEach(function (form) {
var timer = null;
form.querySelectorAll('input[type="search"], input[type="text"]').forEach(function (input) {
input.addEventListener('input', function () {
clearTimeout(timer);
timer = setTimeout(function () { form.requestSubmit(); }, 400);
});
});
form.querySelectorAll('select').forEach(function (select) {
select.addEventListener('change', function () { form.requestSubmit(); });
});
});
}
// --- Bulk selection ---------------------------------------------------
function initBulkSelect() {
document.querySelectorAll('[data-bulk-scope]').forEach(function (scope) {
var master = scope.querySelector('[data-bulk-all]');
var boxes = function () { return scope.querySelectorAll('[data-bulk-item]'); };
var bar = scope.querySelector('[data-bulk-bar]');
var count = scope.querySelector('[data-bulk-count]');
function refresh() {
var selected = scope.querySelectorAll('[data-bulk-item]:checked').length;
if (bar) { bar.classList.toggle('is-visible', selected > 0); }
if (count) { count.textContent = String(selected); }
if (master) {
var total = boxes().length;
master.checked = total > 0 && selected === total;
master.indeterminate = selected > 0 && selected < total;
}
}
if (master) {
master.addEventListener('change', function () {
boxes().forEach(function (b) { b.checked = master.checked; });
refresh();
});
}
scope.addEventListener('change', function (ev) {
if (ev.target.matches('[data-bulk-item]')) { refresh(); }
});
refresh();
});
}
// --- Copy to clipboard ------------------------------------------------
function initCopyButtons() {
document.addEventListener('click', function (ev) {
var btn = ev.target.closest('[data-copy]');
if (!btn) { return; }
ev.preventDefault();
var text = btn.getAttribute('data-copy');
var target = btn.getAttribute('data-copy-target');
if (target) {
var el = document.querySelector(target);
if (el) { text = el.textContent.trim(); }
}
if (!text) { return; }
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () {
window.vibednsToast('success', 'Copied to clipboard.');
}, function () {
window.vibednsToast('warning', 'The browser refused clipboard access.');
});
} else {
window.vibednsToast('warning', 'Clipboard access needs a secure (HTTPS) connection.');
}
});
}
// --- Reverse zone preview --------------------------------------------
/** Shows which reverse zone a subnet will produce, before submitting. */
function initReversePreview() {
var input = document.getElementById('reverseCidr');
var out = document.getElementById('reversePreview');
if (!input || !out) { return; }
var timer = null;
function update() {
var value = input.value.trim();
if (!value) { out.textContent = ''; return; }
fetch('/api/v1/tools/reverse-zone?cidr=' + encodeURIComponent(value), {
headers: { 'Accept': 'application/json' }
}).then(function (r) { return r.json(); }).then(function (data) {
if (data.error) {
out.className = 'form-text text-danger';
out.textContent = data.error.message || 'That is not a valid subnet.';
return;
}
out.className = 'form-text text-success';
out.textContent = 'Zone: ' + data.zone + (data.note ? ' — ' + data.note : '');
}).catch(function () {
out.textContent = '';
});
}
input.addEventListener('input', function () {
clearTimeout(timer);
timer = setTimeout(update, 300);
});
if (input.value) { update(); }
}
// --- Page data --------------------------------------------------------
/* Page data is delivered in data- attributes rather than inline <script>
* blocks, because the Content-Security-Policy only allows scripts served
* from this origin. */
function readData(selector, attr) {
var el = document.querySelector(selector);
if (!el) { return null; }
var raw = el.getAttribute(attr);
if (!raw) { return null; }
try { return JSON.parse(raw); } catch (e) { return null; }
}
// --- Record editor ----------------------------------------------------
/* The record form is generated from the type catalogue the server embeds in
* the page, so every supported type gets a proper labelled editor without a
* hand-written form per type. */
function initRecordEditor() {
var root = document.getElementById('recordEditor');
if (!root) { return; }
var catalogue = readData('#recordEditor', 'data-types');
var values = readData('#recordEditor', 'data-values') || {};
if (!catalogue) { return; }
var typeSelect = root.querySelector('[data-record-type]');
var fieldsEl = root.querySelector('[data-record-fields]');
var rawWrap = root.querySelector('[data-record-raw]');
var rawInput = rawWrap ? rawWrap.querySelector('textarea, input') : null;
var advancedToggle = root.querySelector('[data-record-advanced]');
if (!typeSelect || !fieldsEl) { return; }
function infoFor(type) {
for (var i = 0; i < catalogue.length; i++) {
if (catalogue[i].type === type) { return catalogue[i]; }
}
return null;
}
function buildField(field, value) {
var col = document.createElement('div');
col.className = 'col-12 col-md-' + (field.width || 12);
var label = document.createElement('label');
label.className = 'form-label';
label.textContent = field.label;
label.setAttribute('for', 'field_' + field.key);
col.appendChild(label);
var input;
if (field.type === 'textarea') {
input = document.createElement('textarea');
input.rows = 3;
} else if (field.type === 'select') {
input = document.createElement('select');
(field.options || []).forEach(function (opt) {
var o = document.createElement('option');
o.value = opt;
o.textContent = opt;
if (opt === value) { o.selected = true; }
input.appendChild(o);
});
} else {
input = document.createElement('input');
input.type = field.type === 'number' ? 'number' : 'text';
}
input.className = field.type === 'select' ? 'form-select' : 'form-control';
input.name = 'field_' + field.key;
input.id = 'field_' + field.key;
if (field.placeholder) { input.placeholder = field.placeholder; }
if (field.required) { input.required = true; }
if (input.tagName !== 'SELECT' && value != null) { input.value = value; }
col.appendChild(input);
if (field.help) {
var help = document.createElement('div');
help.className = 'form-text';
help.textContent = field.help;
col.appendChild(help);
}
return col;
}
function render() {
var type = typeSelect.value;
var advanced = advancedToggle && advancedToggle.checked;
var info = infoFor(type);
fieldsEl.innerHTML = '';
if (advanced || !info) {
fieldsEl.classList.add('d-none');
if (rawWrap) { rawWrap.classList.remove('d-none'); }
if (rawInput) { rawInput.disabled = false; }
return;
}
fieldsEl.classList.remove('d-none');
if (rawWrap) { rawWrap.classList.add('d-none'); }
if (rawInput) { rawInput.disabled = true; }
info.fields.forEach(function (field) {
fieldsEl.appendChild(buildField(field, values[field.key]));
});
}
typeSelect.addEventListener('change', function () {
// Values from the previously selected type no longer apply.
values = {};
render();
});
if (advancedToggle) { advancedToggle.addEventListener('change', render); }
render();
// One modal serves both "add" and "edit": the button that opened it
// carries the record in data- attributes, which are read here.
var modalEl = document.getElementById('recordModal');
if (!modalEl) { return; }
var form = document.getElementById('recordForm');
var titleEl = document.getElementById('recordModalLabel');
modalEl.addEventListener('show.bs.modal', function (ev) {
var trigger = ev.relatedTarget;
if (!trigger) { return; }
var d = trigger.dataset;
var isNew = d.recordNew !== undefined;
if (form && d.recordAction) { form.action = d.recordAction; }
if (titleEl) { titleEl.textContent = isNew ? 'Add record' : 'Edit record'; }
var nameEl = document.getElementById('recordName');
var ttlEl = document.getElementById('recordTTL');
var commentEl = document.getElementById('recordComment');
var enabledEl = document.getElementById('recordEnabled');
var rawEl = document.getElementById('recordData');
if (nameEl) { nameEl.value = isNew ? '@' : (d.recordName || '@'); }
if (ttlEl) { ttlEl.value = isNew ? '' : (d.recordTtl || ''); }
if (commentEl) { commentEl.value = isNew ? '' : (d.recordComment || ''); }
if (enabledEl) { enabledEl.checked = isNew ? true : d.recordEnabled === 'true'; }
if (rawEl) { rawEl.value = isNew ? '' : (d.recordData || ''); }
if (advancedToggle) { advancedToggle.checked = false; }
if (!isNew && d.recordRtype) { typeSelect.value = d.recordRtype; }
if (isNew) { typeSelect.value = 'A'; }
values = {};
if (!isNew && d.recordValues) {
try { values = JSON.parse(d.recordValues) || {}; } catch (e) { values = {}; }
}
render();
});
}
// --- Assorted behaviour ----------------------------------------------
/* The Content-Security-Policy forbids inline handlers, so anything that
* would normally be an onclick attribute is delegated from here. */
function initDelegatedActions() {
document.addEventListener('click', function (ev) {
if (ev.target.closest('[data-history-back]')) {
ev.preventDefault();
history.back();
}
});
// The restore modal is shared by every backup row; the row that opened it
// supplies the file name.
var restoreModal = document.getElementById('restoreModal');
if (restoreModal) {
restoreModal.addEventListener('show.bs.modal', function (ev) {
var trigger = ev.relatedTarget;
if (!trigger) { return; }
var name = trigger.getAttribute('data-backup-name') || '';
var form = document.getElementById('restoreForm');
var nameEl = document.getElementById('restoreName');
var confirmEl = document.getElementById('restoreConfirm');
if (form) {
form.action = '/settings/database/backup/' + encodeURIComponent(name) + '/restore';
}
if (nameEl) { nameEl.textContent = name; }
if (confirmEl) { confirmEl.value = ''; confirmEl.placeholder = name; }
});
}
}
// --- Charts -----------------------------------------------------------
function chartColours() {
var dark = document.documentElement.getAttribute('data-bs-theme') === 'dark';
return {
grid: dark ? 'rgba(255,255,255,.08)' : 'rgba(16,24,40,.08)',
text: dark ? '#adb5bd' : '#6c757d',
series: ['#1b6ec2', '#d63939', '#2fb344', '#f59f00', '#7048e8', '#0ca678']
};
}
function initCharts() {
if (typeof Chart === 'undefined') { return; }
var c = chartColours();
Chart.defaults.color = c.text;
Chart.defaults.font.family = getComputedStyle(document.body).fontFamily;
Chart.defaults.animation = false;
var activity = readData('#chartData', 'data-activity');
var types = readData('#chartData', 'data-types');
var activityEl = document.getElementById('activityChart');
if (activityEl && activity) {
var labels = activity.map(function (b) {
var d = new Date(b.start);
return d.getHours().toString().padStart(2, '0') + ':' +
d.getMinutes().toString().padStart(2, '0');
});
new Chart(activityEl, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Total', data: activity.map(function (b) { return b.total; }),
borderColor: c.series[0], backgroundColor: 'rgba(27,110,194,.12)',
fill: true, tension: .3, pointRadius: 0, borderWidth: 2
},
{
label: 'Blocked', data: activity.map(function (b) { return b.blocked; }),
borderColor: c.series[1], backgroundColor: 'rgba(214,57,57,.12)',
fill: true, tension: .3, pointRadius: 0, borderWidth: 2
},
{
label: 'Cached', data: activity.map(function (b) { return b.cached; }),
borderColor: c.series[2], backgroundColor: 'rgba(47,179,68,.10)',
fill: true, tension: .3, pointRadius: 0, borderWidth: 2
}
]
},
options: {
responsive: true, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, usePointStyle: true } } },
scales: {
x: { grid: { display: false }, ticks: { maxTicksLimit: 12 } },
y: { beginAtZero: true, grid: { color: c.grid }, ticks: { precision: 0 } }
}
}
});
}
var typeEl = document.getElementById('typeChart');
if (typeEl && types && types.length) {
new Chart(typeEl, {
type: 'doughnut',
data: {
labels: types.map(function (t) { return t.label; }),
datasets: [{
data: types.map(function (t) { return t.value; }),
backgroundColor: c.series,
borderWidth: 0
}]
},
options: {
responsive: true, maintainAspectRatio: false, cutout: '62%',
plugins: { legend: { position: 'right', labels: { boxWidth: 12, usePointStyle: true } } }
}
});
}
}
// --- Boot -------------------------------------------------------------
document.addEventListener('DOMContentLoaded', function () {
initTheme();
initToasts();
initConfirmations();
initAutoFilters();
initBulkSelect();
initCopyButtons();
initReversePreview();
initRecordEditor();
initDelegatedActions();
initCharts();
});
})();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+125
View File
@@ -0,0 +1,125 @@
<!doctype html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{{.Title}} · VibeDNS</title>
<link rel="icon" href="/static/img/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<a class="visually-hidden-focusable skip-link" href="#main">Skip to main content</a>
<nav class="navbar navbar-expand-lg app-navbar sticky-top">
<div class="container-fluid">
<button class="btn btn-sm btn-outline-light d-lg-none me-2" type="button"
data-bs-toggle="offcanvas" data-bs-target="#sidebarOffcanvas"
aria-controls="sidebarOffcanvas" aria-label="Open navigation">
<i class="bi bi-list"></i>
</button>
<a class="navbar-brand d-flex align-items-center gap-2" href="/">
<i class="bi bi-hdd-network-fill"></i>
<span>VibeDNS</span>
</a>
<div class="ms-auto d-flex align-items-center gap-3">
<button class="btn btn-sm btn-outline-light" type="button" id="themeToggle"
title="Switch between light and dark" aria-label="Switch colour theme">
<i class="bi bi-circle-half"></i>
</button>
<div class="dropdown">
<button class="btn btn-sm btn-outline-light dropdown-toggle" type="button"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-person-circle me-1"></i>{{.User.Name}}
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/account"><i class="bi bi-key me-2"></i>Account</a></li>
<li><a class="dropdown-item" href="/settings/api"><i class="bi bi-code-slash me-2"></i>API tokens</a></li>
<li><hr class="dropdown-divider"></li>
<li><span class="dropdown-item-text text-body-secondary small">Version {{.Version}}</span></li>
</ul>
</div>
</div>
</div>
</nav>
<div class="app-shell">
<aside class="app-sidebar d-none d-lg-block">
{{template "sidebar" .}}
</aside>
<div class="offcanvas offcanvas-start app-offcanvas" tabindex="-1" id="sidebarOffcanvas"
aria-labelledby="sidebarOffcanvasLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="sidebarOffcanvasLabel">Navigation</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body p-0">
{{template "sidebar" .}}
</div>
</div>
<main class="app-main" id="main">
<div class="container-fluid py-4">
{{range .Alerts}}
<div class="alert alert-{{.Level}} d-flex align-items-start gap-3" role="alert">
<i class="bi bi-exclamation-triangle-fill fs-5 mt-1"></i>
<div class="flex-grow-1">
<div class="fw-semibold">{{.Title}}</div>
<div class="small">{{.Message}}</div>
</div>
{{if .Link}}
<a href="{{.Link}}" class="btn btn-sm btn-outline-dark flex-shrink-0">{{.LinkText}}</a>
{{end}}
</div>
{{end}}
{{block "content" .}}{{end}}
</div>
</main>
</div>
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toastContainer">
{{range .Flashes}}
{{if ne .Level "success"}}
<div class="toast align-items-center border-0 text-bg-{{.Level}}" role="alert"
aria-live="assertive" aria-atomic="true" data-autohide="{{if eq .Level "danger"}}false{{else}}true{{end}}">
<div class="d-flex">
<div class="toast-body">{{.Message}}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto"
data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{{end}}
{{end}}
</div>
<div class="modal fade" id="confirmModal" tabindex="-1" aria-hidden="true" aria-labelledby="confirmModalLabel">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="confirmModalLabel">Are you sure?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="mb-0" id="confirmModalBody">This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmModalAccept">Confirm</button>
</div>
</div>
</div>
</div>
<script src="/static/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/app.js"></script>
{{block "scripts" .}}{{end}}
</body>
</html>
+127
View File
@@ -0,0 +1,127 @@
{{define "content"}}
{{$a := .Data.Admin}}
<h1 class="page-title mb-1">Account</h1>
<p class="text-body-secondary mb-4">The single administrator account for this server.</p>
{{template "settingsnav" dict "Subnav" "account"}}
<div class="row g-3">
<div class="col-12 col-lg-7">
<div class="card">
<div class="card-header">Change credentials</div>
<div class="card-body">
{{if $a.MustChangePassword}}
<div class="alert alert-warning d-flex align-items-start gap-2">
<i class="bi bi-shield-exclamation mt-1"></i>
<div>
This account still uses the password that was generated and printed at first
startup. Set your own before exposing the interface to anyone else.
</div>
</div>
{{end}}
<form method="post" action="/account" autocomplete="off">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="mb-3">
<label class="form-label" for="currentPassword">
Current password <span class="text-danger">*</span>
</label>
<input type="password" class="form-control" id="currentPassword" name="current_password"
required autocomplete="current-password">
<div class="form-text">
Always required, even for a username change. Browsers replay HTTP Basic
credentials automatically, so proving you know the password is what
distinguishes you from a hijacked tab.
</div>
</div>
<hr>
<div class="mb-3">
<label class="form-label" for="newUsername">Username</label>
<input type="text" class="form-control" id="newUsername" name="username"
value="{{$a.Username}}" autocomplete="username">
<div class="form-text">Letters, digits and the characters . - _ @</div>
</div>
<div class="mb-3">
<label class="form-label" for="newPassword">New password</label>
<input type="password" class="form-control" id="newPassword" name="new_password"
autocomplete="new-password" minlength="12">
<div class="form-text">
At least 12 characters. Leave blank to keep the current password.
Stored as an Argon2id hash, never in plain text.
</div>
</div>
<div class="mb-3">
<label class="form-label" for="confirmPassword">Confirm new password</label>
<input type="password" class="form-control" id="confirmPassword" name="confirm_password"
autocomplete="new-password">
</div>
<div class="alert alert-info small">
<i class="bi bi-info-circle me-1"></i>
After saving, your browser will prompt for the new credentials. The old password
stops working immediately.
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save credentials
</button>
</form>
</div>
</div>
</div>
<div class="col-12 col-lg-5">
<div class="card mb-3">
<div class="card-header">Account details</div>
<div class="card-body">
<dl class="row mb-0 small">
<dt class="col-5 text-body-secondary">Username</dt>
<dd class="col-7 mono">{{$a.Username}}</dd>
<dt class="col-5 text-body-secondary">Created</dt>
<dd class="col-7">{{datetime $a.CreatedAt}}</dd>
<dt class="col-5 text-body-secondary">Last updated</dt>
<dd class="col-7">{{datetime $a.UpdatedAt}}</dd>
<dt class="col-5 text-body-secondary">Last sign-in</dt>
<dd class="col-7">
{{if $a.LastLoginAt}}{{datetime $a.LastLoginAt}}{{else}}never{{end}}
</dd>
<dt class="col-5 text-body-secondary">Password</dt>
<dd class="col-7">
<span class="badge text-bg-success">Argon2id</span>
</dd>
</dl>
</div>
</div>
<div class="card">
<div class="card-header">How authentication works</div>
<div class="card-body small text-body-secondary">
<p>
The management interface uses HTTP Basic authentication over a single
administrator account. There is no session cookie to steal, but browsers do
replay the credentials on every request, so state-changing requests additionally
carry a signed CSRF token.
</p>
<p>
Repeated failed sign-ins from one address are locked out for a few minutes.
</p>
<p class="mb-0">
For automation, use an <a href="/settings/api">API token</a> rather than the
administrator password: tokens are individually revocable and carry no ability to
change credentials.
</p>
</div>
</div>
</div>
</div>
{{end}}
+86
View File
@@ -0,0 +1,86 @@
{{define "content"}}
{{$f := .Data.Filter}}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 class="page-title">Audit Log</h1>
<p class="text-body-secondary mb-0">
Every administrative change, whether made in this interface, through the REST API,
or on the command line. Passwords and token secrets are never recorded.
</p>
</div>
</div>
<div class="card">
<div class="card-body">
<form method="get" data-autosubmit class="row g-2 align-items-end mb-3">
<div class="col-12 col-md-4">
<label class="form-label small" for="aSearch">Search</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" id="aSearch" name="q" value="{{$f.Search}}"
placeholder="Action, object or detail...">
</div>
</div>
<div class="col-6 col-md-3">
<label class="form-label small" for="aObject">Object type</label>
<select class="form-select form-select-sm" id="aObject" name="object">
<option value="">All</option>
{{range list "zone" "record" "network" "policy" "list" "domain" "settings" "cache" "admin" "api_token" "backup" "config" "query_log"}}
<option value="{{.}}" {{if eq . $f.ObjectType}}selected{{end}}>{{title .}}</option>
{{end}}
</select>
</div>
<div class="col-6 col-md-3">
<label class="form-label small" for="aSource">Source</label>
<select class="form-select form-select-sm" id="aSource" name="source">
<option value="">All</option>
{{range list "web" "api" "cli" "system"}}
<option value="{{.}}" {{if eq . $f.Source}}selected{{end}}>{{upper .}}</option>
{{end}}
</select>
</div>
<div class="col-auto">
<a href="/audit" class="btn btn-sm btn-link">Clear filters</a>
</div>
</form>
{{if .Data.Entries}}
<div class="table-responsive">
<table class="table table-hover align-middle table-compact">
<thead>
<tr>
<th>Time</th><th>Actor</th><th>Source</th><th>Action</th>
<th>Object</th><th>Details</th><th>Client</th>
</tr>
</thead>
<tbody>
{{range .Data.Entries}}
<tr>
<td class="small text-nowrap" title="{{datetime .Timestamp}}">{{timeAgo .Timestamp}}</td>
<td class="small fw-semibold">{{.Actor}}</td>
<td><span class="badge text-bg-light text-dark">{{upper .Source}}</span></td>
<td class="mono small">{{.Action}}</td>
<td class="small">
{{if .ObjectName}}
<span class="mono">{{truncate 40 .ObjectName}}</span>
{{end}}
{{if .ObjectType}}
<div class="text-body-secondary">{{.ObjectType}}{{if .ObjectID}} #{{.ObjectID}}{{end}}</div>
{{end}}
</td>
<td class="small text-body-secondary truncate-cell" title="{{.Details}}">{{.Details}}</td>
<td class="mono small text-body-secondary">{{.ClientIP}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{template "pagination" dict "P" .Data.Pagination "Q" .Query}}
{{else}}
{{template "empty" dict "Icon" "bi-clipboard-check" "Title" "No audit entries match"
"Message" "Administrative changes are recorded here as they happen. Clear the filters if you expected to see something."}}
{{end}}
</div>
</div>
{{end}}
+116
View File
@@ -0,0 +1,116 @@
{{define "content"}}
{{$c := .Data.Stats}}{{$csrf := .CSRF}}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 class="page-title">Cache</h1>
<p class="text-body-secondary mb-0">Responses held in memory to answer repeat queries instantly.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a href="/settings/cache" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-gear me-1"></i>Cache settings
</a>
{{template "confirmform" dict
"Action" "/cache/flush" "CSRF" $csrf
"Label" "Flush entire cache" "Icon" "bi-trash"
"Class" "btn btn-sm btn-danger"
"Message" "Flush every cached response? Queries will go back upstream until the cache refills."}}
</div>
</div>
{{if not $c.Enabled}}
<div class="alert alert-secondary d-flex align-items-center gap-2">
<i class="bi bi-info-circle"></i>
<div>The cache is turned off. Every recursive query is forwarded upstream.</div>
</div>
{{end}}
<div class="row g-3 mb-4">
{{template "stat" dict "Label" "Entries" "Value" (num $c.Entries) "Icon" "bi-database"
"Sub" (printf "limit %s" (num $c.MaxEntries))}}
{{template "stat" dict "Label" "Hit rate" "Value" (pct $c.HitRate) "Icon" "bi-lightning-charge" "Tone" "success"}}
{{template "stat" dict "Label" "Hits" "Value" (num $c.Hits) "Icon" "bi-check-circle" "Tone" "success"
"Sub" (printf "%s served stale" (num $c.StaleHits))}}
{{template "stat" dict "Label" "Misses" "Value" (num $c.Misses) "Icon" "bi-x-circle"}}
{{template "stat" dict "Label" "Memory estimate" "Value" (bytes $c.Bytes) "Icon" "bi-memory"}}
{{template "stat" dict "Label" "Insertions" "Value" (num $c.Insertions) "Icon" "bi-box-arrow-in-down"}}
{{template "stat" dict "Label" "Evictions" "Value" (num $c.Evictions) "Icon" "bi-box-arrow-up"
"Sub" "least recently used first"}}
{{template "stat" dict "Label" "Expirations" "Value" (num $c.Expirations) "Icon" "bi-hourglass-bottom"}}
</div>
<div class="card">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<span>Cached entries</span>
<span class="small text-body-secondary">
TTL clamped to {{$c.MinTTL}}{{$c.MaxTTL}}s · negative {{$c.NegativeTTL}}s
{{if $c.ServeStale}}· serves stale for {{$c.StaleTTL}}s{{end}}
{{if $c.Prefetch}}· prefetch on{{end}}
</span>
</div>
<div class="card-body">
<form method="get" data-autosubmit class="row g-2 align-items-center mb-3">
<div class="col-12 col-md-6 col-lg-4">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" name="q" value="{{.Data.Search}}"
placeholder="Search cached names..." aria-label="Search cached entries">
</div>
</div>
<div class="col-auto">
<noscript><button class="btn btn-sm btn-outline-secondary" type="submit">Search</button></noscript>
</div>
</form>
{{if .Data.Entries}}
<div class="table-responsive">
<table class="table table-hover align-middle table-compact">
<thead>
<tr>
<th>Name</th><th>Type</th><th>Result</th>
<th class="text-end">Answers</th><th class="text-end">TTL left</th>
<th class="text-end">Size</th><th>Cached</th><th class="row-actions">Actions</th>
</tr>
</thead>
<tbody>
{{range .Data.Entries}}
<tr {{if .Stale}}class="is-disabled"{{end}}>
<td class="mono truncate-cell" title="{{.Name}}">{{trimDot .Name}}</td>
<td>
<span class="type-chip {{typeBadge .Type}}">{{.Type}}</span>
{{if .DO}}<span class="badge text-bg-light text-dark ms-1" title="DNSSEC answer">+do</span>{{end}}
</td>
<td>
<span class="badge {{rcodeBadge .Rcode}}">{{.Rcode}}</span>
{{if .Negative}}<span class="badge text-bg-light text-dark ms-1">negative</span>{{end}}
</td>
<td class="text-end">{{.Answers}}</td>
<td class="text-end">
{{if .Stale}}<span class="text-warning">stale</span>{{else}}{{.TTL}}s{{end}}
</td>
<td class="text-end small text-body-secondary">{{bytes .Size}}</td>
<td class="small text-body-secondary">{{timeAgo .Stored}}</td>
<td class="row-actions">
{{template "confirmform" dict
"Action" "/cache/delete" "CSRF" $csrf
"Icon" "bi-trash" "Title" "Remove this entry"
"Fields" (dict "name" .Name "type" .Type "dnssec" (boolstr .DO))
"Message" (printf "Remove the cached %s answer for %s?" .Type (trimDot .Name))}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{template "pagination" dict "P" .Data.Pagination "Q" .Query}}
{{else if .Data.Search}}
{{template "empty" dict "Icon" "bi-search" "Title" "Nothing cached matches that name"
"Message" "The entry may have expired, or the name may never have been queried."}}
{{else}}
{{template "empty" dict "Icon" "bi-lightning-charge" "Title" "The cache is empty"
"Message" "Recursive answers are cached here as clients query them."}}
{{end}}
</div>
</div>
{{end}}
+270
View File
@@ -0,0 +1,270 @@
{{define "content"}}
{{$d := .Data.D}}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 class="page-title">Dashboard</h1>
<p class="text-body-secondary mb-0">
{{$d.Hostname}} · up {{$d.UptimeText}} · VibeDNS {{.Version}}
</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a href="/tools" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-search me-1"></i>Test a lookup
</a>
<a href="/querylog" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-journal-text me-1"></i>Query log
</a>
</div>
</div>
{{/* Service status strip */}}
<div class="card mb-4">
<div class="card-body py-3">
<div class="row g-3 align-items-center">
<div class="col-12 col-md-4 d-flex align-items-center gap-2">
<span class="status-dot {{if $d.DNSRunning}}status-up{{else}}status-down{{end}}"></span>
<div>
<div class="fw-semibold">{{if $d.DNSRunning}}DNS listeners running{{else}}DNS listeners down{{end}}</div>
<div class="small text-body-secondary mono">
UDP {{.Data.UDPAddr}} · TCP {{.Data.TCPAddr}}
</div>
</div>
</div>
<div class="col-6 col-md-3 d-flex align-items-center gap-2">
<span class="status-dot {{if $d.RecursionEnabled}}status-up{{else}}status-down{{end}}"></span>
<div>
<div class="fw-semibold">Recursion {{if $d.RecursionEnabled}}on{{else}}off{{end}}</div>
<div class="small text-body-secondary">
{{len $d.Upstreams}} upstream{{if ne (len $d.Upstreams) 1}}s{{end}}
</div>
</div>
</div>
<div class="col-6 col-md-3 d-flex align-items-center gap-2">
<span class="status-dot {{if $d.CacheEnabled}}status-up{{else}}status-down{{end}}"></span>
<div>
<div class="fw-semibold">Cache {{if $d.CacheEnabled}}on{{else}}off{{end}}</div>
<div class="small text-body-secondary">{{num $d.CacheEntries}} entries</div>
</div>
</div>
<div class="col-12 col-md-2 text-md-end">
<div class="small text-body-secondary">Queries per second</div>
<div class="fs-5 fw-semibold">{{printf "%.1f" $d.QueriesPerSec}}</div>
</div>
</div>
</div>
</div>
{{/* Headline counters */}}
<div class="row g-3 mb-4">
{{template "stat" dict "Label" "Total queries" "Value" (num $d.TotalQueries) "Icon" "bi-arrow-left-right"
"Sub" (printf "%s avg response" (ms $d.AvgQueryMS))}}
{{template "stat" dict "Label" "Blocked" "Value" (num $d.Blocked) "Icon" "bi-shield-slash" "Tone" "danger"
"Sub" (printf "%s of all queries" (pct $d.BlockRate))}}
{{template "stat" dict "Label" "Cache hit rate" "Value" (pct $d.CacheHitRate) "Icon" "bi-lightning-charge" "Tone" "success"
"Sub" (printf "%s hits · %s misses" (num $d.CacheHits) (num $d.CacheMisses))}}
{{template "stat" dict "Label" "Authoritative" "Value" (num $d.Authoritative) "Icon" "bi-diagram-3" "Tone" "primary"
"Sub" (printf "%s zones · %s records" (num $d.Zones) (num $d.Records))}}
{{template "stat" dict "Label" "Recursive" "Value" (num $d.Recursive) "Icon" "bi-globe2"
"Sub" (printf "%s upstream latency" (ms $d.AvgResolverMS))}}
{{template "stat" dict "Label" "Cache entries" "Value" (num $d.CacheEntries) "Icon" "bi-database"
"Sub" (bytes $d.CacheBytes)}}
{{template "stat" dict "Label" "Blacklist domains" "Value" (num $d.BlacklistDomains) "Icon" "bi-list-ul"
"Sub" (printf "across %s blacklists" (num $d.Blacklists))}}
{{template "stat" dict "Label" "Refused" "Value" (num $d.Refused) "Icon" "bi-hand-index" "Tone" "warning"
"Sub" (printf "%s rate limited · %s errors" (num $d.RateLimited) (num $d.Errors))}}
</div>
{{/* Activity + type breakdown */}}
<div class="row g-3 mb-4">
<div class="col-12 col-xl-8">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Query activity — last 24 hours</span>
{{if not $d.QueryLogEnabled}}
<span class="badge text-bg-secondary">query logging off</span>
{{end}}
</div>
<div class="card-body">
{{if and $d.QueryLogEnabled $d.Activity}}
<div class="chart-wrap"><canvas id="activityChart" aria-label="Query activity chart" role="img"></canvas></div>
{{else}}
{{template "empty" dict "Icon" "bi-graph-up" "Title" "No activity to chart yet"
"Message" "Query logging must be enabled for the activity chart. Once queries arrive they appear here."}}
{{end}}
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card h-100">
<div class="card-header">Queries by type</div>
<div class="card-body">
{{if $d.QueriesByType}}
<div class="chart-wrap-sm"><canvas id="typeChart" aria-label="Queries by record type" role="img"></canvas></div>
{{else}}
{{template "empty" dict "Icon" "bi-pie-chart" "Title" "No queries yet"
"Message" "Point a client at this server and the breakdown appears here."}}
{{end}}
</div>
</div>
</div>
</div>
{{/* Top lists */}}
<div class="row g-3 mb-4">
<div class="col-12 col-lg-4">
<div class="card h-100">
<div class="card-header">Top queried domains</div>
{{if $d.TopDomains}}
<div class="table-responsive">
<table class="table table-sm table-compact table-hover">
<tbody>
{{range $d.TopDomains}}
<tr>
<td class="mono truncate-cell" title="{{.Name}}">{{trimDot .Name}}</td>
<td class="text-end fw-semibold">{{num .Count}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="card-body">
{{template "empty" dict "Icon" "bi-bar-chart" "Title" "Nothing yet" "Message" "Queries from the last 24 hours appear here."}}
</div>
{{end}}
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card h-100">
<div class="card-header">Top blocked domains</div>
{{if $d.TopBlocked}}
<div class="table-responsive">
<table class="table table-sm table-compact table-hover">
<tbody>
{{range $d.TopBlocked}}
<tr>
<td class="mono truncate-cell" title="{{.Name}}">{{trimDot .Name}}</td>
<td class="text-end fw-semibold text-danger">{{num .Count}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="card-body">
{{template "empty" dict "Icon" "bi-shield-check" "Title" "Nothing blocked"
"Message" "Assign a blacklist to a client network to start filtering."}}
</div>
{{end}}
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card h-100">
<div class="card-header">Top clients</div>
{{if $d.TopClients}}
<div class="table-responsive">
<table class="table table-sm table-compact table-hover">
<tbody>
{{range $d.TopClients}}
<tr>
<td>
<div class="mono">{{.Name}}</div>
{{if .Extra}}<div class="small text-body-secondary">{{.Extra}}</div>{{end}}
</td>
<td class="text-end fw-semibold align-middle">{{num .Count}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="card-body">
{{template "empty" dict "Icon" "bi-people" "Title" "No clients yet" "Message" "Clients that query this server appear here."}}
</div>
{{end}}
</div>
</div>
</div>
{{/* Upstreams and recent activity */}}
<div class="row g-3">
<div class="col-12 col-xl-5">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Upstream resolvers</span>
<a href="/settings/resolver" class="btn btn-sm btn-outline-secondary">Configure</a>
</div>
<div class="table-responsive">
<table class="table table-sm table-compact">
<thead>
<tr><th>Server</th><th>Status</th><th class="text-end">Latency</th><th class="text-end">Queries</th></tr>
</thead>
<tbody>
{{range $d.Upstreams}}
<tr>
<td class="mono">{{.Address}}</td>
<td>
{{if .Healthy}}<span class="badge text-bg-success">healthy</span>
{{else}}<span class="badge text-bg-danger" title="{{.LastError}}">resting</span>{{end}}
</td>
<td class="text-end">{{ms .LatencyMS}}</td>
<td class="text-end">{{num .Queries}}</td>
</tr>
{{else}}
<tr><td colspan="4" class="text-center text-body-secondary py-3">No upstream resolvers configured.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-12 col-xl-7">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Recent DNS activity</span>
<a href="/querylog" class="btn btn-sm btn-outline-secondary">View all</a>
</div>
{{if $d.Recent}}
<div class="table-responsive">
<table class="table table-sm table-compact table-hover">
<thead>
<tr><th>Time</th><th>Client</th><th>Query</th><th>Type</th><th>Result</th><th>Source</th></tr>
</thead>
<tbody>
{{range $d.Recent}}
<tr>
<td class="text-body-secondary small text-nowrap">{{timeOnly .Timestamp}}</td>
<td class="mono small">{{.ClientIP}}</td>
<td class="mono truncate-cell" title="{{.QName}}">{{trimDot .QName}}</td>
<td><span class="type-chip {{typeBadge .QType}}">{{.QType}}</span></td>
<td><span class="badge {{rcodeBadge .Rcode}}">{{.Rcode}}</span></td>
<td><span class="badge {{sourceBadge .Source}}">{{.Source}}</span></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="card-body">
{{template "empty" dict "Icon" "bi-clock-history" "Title" "No recent activity"
"Message" "Recent queries appear here once query logging is enabled and clients start resolving."}}
</div>
{{end}}
</div>
</div>
</div>
{{/* Chart data travels in data- attributes rather than an inline script, so the
page needs no CSP exception. app.js reads and parses it. */}}
<div id="chartData" hidden
data-activity="{{json $d.Activity}}"
data-types="{{json $d.QueriesByType}}"></div>
{{end}}
{{define "scripts"}}
<script src="/static/js/chart.umd.js"></script>
{{end}}
+19
View File
@@ -0,0 +1,19 @@
{{define "content"}}
<div class="row justify-content-center">
<div class="col-12 col-lg-7">
<div class="card">
<div class="card-body text-center py-5">
<div class="display-4 fw-semibold text-body-secondary">{{.Data.Status}}</div>
<h1 class="h4 mt-2 mb-3">{{.Data.Text}}</h1>
<p class="text-body-secondary mx-auto" style="max-width: 32rem;">{{.Data.Message}}</p>
{{/* No inline handlers: the Content-Security-Policy allows scripts
only from our own origin, so "go back" is wired up in app.js. */}}
<div class="mt-4 d-flex justify-content-center gap-2">
<a href="/" class="btn btn-primary"><i class="bi bi-house me-1"></i>Dashboard</a>
<button class="btn btn-outline-secondary" type="button" data-history-back>Go back</button>
</div>
</div>
</div>
</div>
</div>
{{end}}
+281
View File
@@ -0,0 +1,281 @@
{{define "content"}}
{{$l := .Data.List}}{{$csrf := .CSRF}}
{{$back := printf "/policies/lists/%d" $l.ID}}
{{$isBlack := eq $l.Kind "blacklist"}}
<nav aria-label="breadcrumb">
<ol class="breadcrumb small">
<li class="breadcrumb-item">
<a href="/policies/{{$l.Kind}}s">{{if $isBlack}}Blacklists{{else}}Allowlists{{end}}</a>
</li>
<li class="breadcrumb-item active" aria-current="page">{{$l.Name}}</li>
</ol>
</nav>
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h1 class="page-title">{{$l.Name}}</h1>
{{if $l.Description}}<p class="text-body-secondary mb-0">{{$l.Description}}</p>{{end}}
</div>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#importListModal">
<i class="bi bi-upload me-1"></i>Import
</button>
<a href="/policies/lists/{{$l.ID}}/export" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-download me-1"></i>Export
</a>
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#addDomainModal">
<i class="bi bi-plus-lg me-1"></i>Add Domain
</button>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown"
aria-expanded="false"><i class="bi bi-three-dots"></i></button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button class="dropdown-item" type="button" data-bs-toggle="modal" data-bs-target="#editListModal">
<i class="bi bi-pencil me-2"></i>Edit list details</button></li>
<li><hr class="dropdown-divider"></li>
<li>
<div class="px-2">
{{template "confirmform" dict
"Action" (printf "/policies/lists/%d/clear" $l.ID) "CSRF" $csrf
"Label" "Remove all domains" "Icon" "bi-eraser"
"Class" "btn btn-sm btn-outline-danger w-100"
"Message" (printf "Remove all %d domains from %q? The list itself is kept." $l.DomainCount $l.Name)}}
</div>
</li>
</ul>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-body">
<dl class="list-meta mb-0">
<div>
<dt>Domains</dt>
<dd>{{num $l.DomainCount}}</dd>
</div>
<div>
<dt>Status</dt>
<dd><span class="badge {{badgeFor $l.Enabled}}">{{statusWord $l.Enabled}}</span></dd>
</div>
<div>
<dt>Updated</dt>
<dd class="fs-6 fw-normal">{{timeAgo $l.UpdatedAt}}</dd>
</div>
<div style="min-width:14rem;">
<dt>Used by</dt>
<dd class="fs-6 fw-normal">
{{if $l.UsedBy}}
<div class="d-flex flex-wrap gap-1">
{{range $l.UsedBy}}<span class="badge text-bg-light text-dark">{{.}}</span>{{end}}
</div>
{{else}}
<span class="text-body-secondary fst-italic">no policies</span>
{{end}}
</dd>
</div>
</dl>
</div>
</div>
{{if and (eq $l.DomainCount 0) (not .Data.Search)}}
{{template "empty" dict "Icon" (pick $isBlack "bi-shield-slash" "bi-shield-check") "Title" "This list is empty"
"Message" "Import a domain list to fill it. Plain lists, hosts files and Adblock-style rules are all understood, and a file with hundreds of thousands of lines is inserted in a single transaction."}}
<div class="text-center pb-4">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#importListModal">
<i class="bi bi-upload me-1"></i>Import domains
</button>
</div>
{{else}}
<div class="card">
<div class="card-body">
<form method="get" data-autosubmit class="row g-2 align-items-center mb-3">
<div class="col-12 col-md-6 col-lg-4">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" name="q" value="{{.Data.Search}}"
placeholder="Search domains..." aria-label="Search domains">
</div>
</div>
<div class="col-auto">
{{if .Data.Search}}<a href="{{$back}}" class="btn btn-sm btn-link">Clear</a>{{end}}
</div>
</form>
{{if .Data.Entries}}
<div class="table-responsive">
<table class="table table-hover align-middle table-compact">
<thead>
<tr>
<th>Domain</th><th>Match</th><th>Status</th>
<th>Comment</th><th>Added</th><th class="row-actions">Actions</th>
</tr>
</thead>
<tbody>
{{range .Data.Entries}}
<tr {{if not .Enabled}}class="is-disabled"{{end}}>
<td class="mono">{{.Domain}}</td>
<td>
{{if .MatchSubdomains}}
<span class="badge text-bg-light text-dark" title="Also matches every subdomain">
<i class="bi bi-asterisk me-1"></i>+ subdomains
</span>
{{else}}
<span class="badge text-bg-light text-dark">exact only</span>
{{end}}
</td>
<td>
<span class="badge {{if $isBlack}}text-bg-danger{{else}}text-bg-success{{end}}">
{{if $isBlack}}Blocked{{else}}Allowed{{end}}
</span>
</td>
<td class="small text-body-secondary truncate-cell">{{.Comment}}</td>
<td class="small text-body-secondary">{{timeAgo .CreatedAt}}</td>
<td class="row-actions">
{{template "confirmform" dict
"Action" (printf "/policies/domains/%d/delete" .ID) "CSRF" $csrf
"ReturnTo" $back "Icon" "bi-trash"
"Message" (printf "Remove %s from %s?" .Domain $l.Name)}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{template "pagination" dict "P" .Data.Pagination "Q" .Query}}
{{else}}
{{template "empty" dict "Icon" "bi-search" "Title" "No domains match that search"
"Message" "Try a different term, or clear the search box."}}
{{end}}
</div>
</div>
{{end}}
{{/* ---- Import modal ---- */}}
<div class="modal fade" id="importListModal" tabindex="-1" aria-hidden="true" aria-labelledby="importListModalLabel">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form method="post" action="/policies/lists/{{$l.ID}}/import" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<div class="modal-header">
<h5 class="modal-title" id="importListModalLabel">Import domains into {{$l.Name}}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label" for="listFile">Upload a file</label>
<input type="file" class="form-control" id="listFile" name="file" accept=".txt,.list,.hosts,text/plain">
<div class="form-text">
Plain lists, hosts files and Adblock-style rules are all accepted, mixed freely.
</div>
</div>
<div class="mb-3">
<label class="form-label" for="listPaste">…or paste domains</label>
<textarea class="form-control list-input" id="listPaste" name="content" rows="8"
placeholder="example.com
0.0.0.0 ads.example.net
||tracker.example.org^
# comments and blank lines are ignored"></textarea>
</div>
<div class="form-check form-switch">
<input type="hidden" name="match_subdomains" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="matchSubs"
name="match_subdomains" value="true" checked>
<label class="form-check-label" for="matchSubs">
Also match subdomains
</label>
<div class="form-text">
With this on, blocking <span class="mono">example.com</span> also covers
<span class="mono">www.example.com</span> and <span class="mono">a.b.example.com</span>,
without storing them individually.
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Import</button>
</div>
</form>
</div>
</div>
</div>
{{/* ---- Add domain modal ---- */}}
<div class="modal fade" id="addDomainModal" tabindex="-1" aria-hidden="true" aria-labelledby="addDomainModalLabel">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="/policies/lists/{{$l.ID}}/domains">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<input type="hidden" name="return_to" value="{{$back}}">
<div class="modal-header">
<h5 class="modal-title" id="addDomainModalLabel">Add a domain</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label" for="domainInput">Domain</label>
<input type="text" class="form-control mono" id="domainInput" name="domain"
placeholder="example.com" required autocomplete="off">
<div class="form-text">A leading <span class="mono">*.</span> is accepted and turns on subdomain matching.</div>
</div>
<div class="mb-3">
<label class="form-label" for="domainComment">Comment</label>
<input type="text" class="form-control" id="domainComment" name="comment"
placeholder="Why this entry exists">
</div>
<div class="form-check form-switch">
<input type="hidden" name="match_subdomains" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="domainSubs"
name="match_subdomains" value="true" checked>
<label class="form-check-label" for="domainSubs">Also match subdomains</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Add domain</button>
</div>
</form>
</div>
</div>
</div>
{{/* ---- Edit list modal ---- */}}
<div class="modal fade" id="editListModal" tabindex="-1" aria-hidden="true" aria-labelledby="editListModalLabel">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="/policies/lists/{{$l.ID}}">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<div class="modal-header">
<h5 class="modal-title" id="editListModalLabel">Edit {{$l.Name}}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label" for="editListName">Name</label>
<input type="text" class="form-control" id="editListName" name="name" value="{{$l.Name}}" required>
</div>
<div class="mb-3">
<label class="form-label" for="editListDescription">Description</label>
<textarea class="form-control" id="editListDescription" name="description" rows="2">{{$l.Description}}</textarea>
</div>
<div class="mb-3">
<label class="form-label" for="editListSource">Source URL</label>
<input type="url" class="form-control" id="editListSource" name="source_url" value="{{$l.SourceURL}}">
</div>
<div class="form-check form-switch">
<input type="hidden" name="enabled" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="editListEnabled"
name="enabled" value="true" {{if $l.Enabled}}checked{{end}}>
<label class="form-check-label" for="editListEnabled">Enabled</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
{{end}}
+156
View File
@@ -0,0 +1,156 @@
{{define "content"}}
{{$csrf := .CSRF}}{{$kind := .Data.Kind}}{{$isBlack := .Data.IsBlacklist}}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 class="page-title">{{.Title}}</h1>
<p class="text-body-secondary mb-0">
{{if $isBlack}}
Reusable sets of domains to block. One list can be attached to several policies.
{{else}}
Domains that must never be blocked. An allowlist match always beats a blacklist match.
{{end}}
</p>
</div>
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newListModal">
<i class="bi bi-plus-lg me-1"></i>Add {{if $isBlack}}Blacklist{{else}}Allowlist{{end}}
</button>
</div>
<div class="card">
<div class="card-body">
<form method="get" data-autosubmit class="row g-2 align-items-center mb-3">
<div class="col-12 col-md-6 col-lg-4">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" name="q" value="{{.Data.Search}}"
placeholder="Search lists..." aria-label="Search lists">
</div>
</div>
</form>
{{if .Data.Lists}}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>List</th><th class="text-end">Domains</th><th>Used by</th>
<th>Updated</th><th>Status</th><th class="row-actions">Actions</th>
</tr>
</thead>
<tbody>
{{range .Data.Lists}}
<tr {{if not .Enabled}}class="is-disabled"{{end}}>
<td>
<a href="/policies/lists/{{.ID}}" class="fw-semibold text-decoration-none">{{.Name}}</a>
{{if .Description}}<div class="small text-body-secondary">{{truncate 80 .Description}}</div>{{end}}
{{if .SourceURL}}
<div class="small text-body-secondary mono">{{truncate 60 .SourceURL}}</div>
{{end}}
</td>
<td class="text-end fw-semibold">{{num .DomainCount}}</td>
<td>
{{if .UsedBy}}
<div class="d-flex flex-wrap gap-1">
{{range .UsedBy}}<span class="badge text-bg-light text-dark">{{.}}</span>{{end}}
</div>
{{else}}
<span class="text-body-secondary small fst-italic">not used by any policy</span>
{{end}}
</td>
<td class="small text-body-secondary">{{timeAgo .UpdatedAt}}</td>
<td><span class="badge {{badgeFor .Enabled}}">{{statusWord .Enabled}}</span></td>
<td class="row-actions">
<div class="btn-group">
<a href="/policies/lists/{{.ID}}" class="btn btn-sm btn-outline-secondary" title="Open list">
<i class="bi bi-list-ul"></i>
</a>
<a href="/policies/lists/{{.ID}}/export" class="btn btn-sm btn-outline-secondary" title="Export domains">
<i class="bi bi-download"></i>
</a>
{{template "postform" dict
"Action" (printf "/policies/lists/%d/toggle" .ID) "CSRF" $csrf
"ReturnTo" (printf "/policies/%ss" $kind)
"Fields" (dict "enabled" (boolstr (not .Enabled)))
"Icon" (toggleIcon .Enabled)
"Class" "btn btn-sm btn-outline-secondary"
"Title" (printf "%s this list" (toggleVerb .Enabled))}}
{{template "confirmform" dict
"Action" (printf "/policies/lists/%d/delete" .ID) "CSRF" $csrf
"Icon" "bi-trash"
"Message" (printf "Delete %q and all %d of its domains? This cannot be undone." .Name .DomainCount)}}
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else if .Data.Search}}
{{template "empty" dict "Icon" "bi-search" "Title" "No lists match that search"
"Message" "Try a different term, or clear the search box."}}
{{else}}
{{if $isBlack}}
{{template "empty" dict "Icon" "bi-shield-slash" "Title" "No blacklists yet"
"Message" "Create a list such as Malware, Advertising or Adult Content, then import domains into it from a plain list, a hosts file, or an Adblock-style rule set."}}
{{else}}
{{template "empty" dict "Icon" "bi-shield-check" "Title" "No allowlists yet"
"Message" "An allowlist holds the domains that must always resolve, even when a blacklist covers them. Attach it to the same policy as the blacklist you want to override."}}
{{end}}
<div class="text-center pb-3">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newListModal">
<i class="bi bi-plus-lg me-1"></i>Create the first list
</button>
</div>
{{end}}
</div>
</div>
<div class="modal fade" id="newListModal" tabindex="-1" aria-hidden="true" aria-labelledby="newListModalLabel">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="/policies/lists/new">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<input type="hidden" name="kind" value="{{$kind}}">
<div class="modal-header">
<h5 class="modal-title" id="newListModalLabel">
New {{if $isBlack}}blacklist{{else}}allowlist{{end}}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label" for="listName">Name</label>
<input type="text" class="form-control" id="listName" name="name" required
placeholder="{{if $isBlack}}Adult Content{{else}}Work Exceptions{{end}}">
</div>
<div class="mb-3">
<label class="form-label" for="listDescription">Description</label>
<textarea class="form-control" id="listDescription" name="description" rows="2"
placeholder="What this list covers"></textarea>
</div>
<div class="mb-3">
<label class="form-label" for="listSource">Source URL</label>
<input type="url" class="form-control" id="listSource" name="source_url"
placeholder="https://example.org/hosts.txt">
<div class="form-text">
Recorded for your reference so you know where to fetch an updated copy.
</div>
</div>
<div class="form-check form-switch">
<input type="hidden" name="enabled" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="listEnabled"
name="enabled" value="true" checked>
<label class="form-check-label" for="listEnabled">Enabled</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Create list</button>
</div>
</form>
</div>
</div>
</div>
{{end}}
+119
View File
@@ -0,0 +1,119 @@
{{define "content"}}
{{$n := .Data.Network}}{{$new := .Data.IsNew}}{{$sel := .Data.Selected}}
<nav aria-label="breadcrumb">
<ol class="breadcrumb small">
<li class="breadcrumb-item"><a href="/policies/networks">Client Networks</a></li>
<li class="breadcrumb-item active" aria-current="page">
{{if $new}}New network{{else}}{{$n.Name}}{{end}}
</li>
</ol>
</nav>
<h1 class="page-title mb-1">{{if $new}}Add a client network{{else}}{{$n.Name}}{{end}}</h1>
{{if not $new}}<p class="text-body-secondary mono mb-4">{{$n.CIDR}}</p>{{else}}<div class="mb-4"></div>{{end}}
<form method="post" action="{{.Data.FormAction}}">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="row g-3">
<div class="col-12 col-lg-5">
<div class="card mb-3">
<div class="card-header">Network</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label" for="netName">Name</label>
<input type="text" class="form-control" id="netName" name="name" value="{{$n.Name}}"
placeholder="Guest Wi-Fi" required>
</div>
<div class="mb-3">
<label class="form-label" for="netCIDR">Subnet</label>
<input type="text" class="form-control mono" id="netCIDR" name="cidr" value="{{$n.CIDR}}"
placeholder="100.64.30.0/24" required autocomplete="off">
<div class="form-text">
IPv4 or IPv6 in CIDR notation. A bare address is treated as a single host.
</div>
</div>
<div class="mb-3">
<label class="form-label" for="netDescription">Description</label>
<textarea class="form-control" id="netDescription" name="description" rows="2"
placeholder="What this network is for">{{$n.Description}}</textarea>
</div>
<div class="form-check form-switch">
<input type="hidden" name="enabled" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="netEnabled"
name="enabled" value="true" {{if or $new $n.Enabled}}checked{{end}}>
<label class="form-check-label" for="netEnabled">Enabled</label>
</div>
</div>
</div>
<div class="card">
<div class="card-body d-grid gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>{{if $new}}Create network{{else}}Save changes{{end}}
</button>
<a href="/policies/networks" class="btn btn-outline-secondary">Cancel</a>
</div>
</div>
</div>
<div class="col-12 col-lg-7">
<div class="card h-100">
<div class="card-header">DNS Policies</div>
<div class="card-body">
<p class="text-body-secondary small">
Tick every policy that should apply to clients in this subnet. Policies are
evaluated together: an allowlist in any of them overrides a blacklist match in
any other.
</p>
{{if .Data.Policies}}
<div class="list-group">
{{range .Data.Policies}}
<label class="list-group-item d-flex gap-3 align-items-start">
<input class="form-check-input flex-shrink-0 mt-1" type="checkbox"
name="policy_id" value="{{.ID}}" {{if index $sel .ID}}checked{{end}}>
<span class="flex-grow-1">
<span class="d-flex justify-content-between align-items-start gap-2">
<span class="fw-semibold">{{.Name}}</span>
<span>
<span class="badge text-bg-light text-dark">{{.BlockAction.Label}}</span>
{{if not .Enabled}}<span class="badge text-bg-secondary">disabled</span>{{end}}
</span>
</span>
{{if .Description}}
<span class="d-block small text-body-secondary">{{.Description}}</span>
{{end}}
<span class="d-block small mt-1">
{{if .BlacklistName}}
<span class="text-body-secondary">Blocks:</span>
{{range $i, $b := .BlacklistName}}{{if $i}}, {{end}}{{$b}}{{end}}
{{end}}
{{if .AllowlistName}}
<span class="text-body-secondary ms-2">Allows:</span>
{{range $i, $a := .AllowlistName}}{{if $i}}, {{end}}{{$a}}{{end}}
{{end}}
{{if and (not .BlacklistName) (not .AllowlistName)}}
<span class="text-body-secondary fst-italic">No lists attached to this policy yet.</span>
{{end}}
</span>
</span>
</label>
{{end}}
</div>
{{else}}
{{template "empty" dict "Icon" "bi-sliders" "Title" "No policies exist yet"
"Message" "A policy pairs a set of blacklists and allowlists with a block action. Create one, then come back and assign it here."}}
<div class="text-center">
<a href="/policies/rules/new" class="btn btn-outline-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>Create a policy
</a>
</div>
{{end}}
</div>
</div>
</div>
</div>
</form>
{{end}}
+113
View File
@@ -0,0 +1,113 @@
{{define "content"}}
{{$csrf := .CSRF}}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 class="page-title">Client Networks</h1>
<p class="text-body-secondary mb-0">
Subnets that DNS policies are applied to. A query is matched against the most
specific network containing the client's address.
</p>
</div>
<a href="/policies/networks/new" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>Add Network
</a>
</div>
{{if not .Data.Policies}}
<div class="alert alert-info d-flex align-items-start gap-2">
<i class="bi bi-info-circle mt-1"></i>
<div>
There are no policies to assign yet. A network without a policy is matched but
filters nothing.
<a href="/policies/rules/new" class="alert-link">Create a policy first</a>.
</div>
</div>
{{end}}
<div class="card">
<div class="card-body">
<form method="get" data-autosubmit class="row g-2 align-items-center mb-3">
<div class="col-12 col-md-6 col-lg-4">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" name="q" value="{{.Data.Search}}"
placeholder="Search networks..." aria-label="Search networks">
</div>
</div>
</form>
{{if .Data.Networks}}
<div class="row g-3">
{{range .Data.Networks}}
<div class="col-12 col-xl-6">
<div class="card h-100 {{if not .Enabled}}opacity-75{{end}}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-2 mb-2">
<div>
<h2 class="h6 mb-1">
<a href="/policies/networks/{{.ID}}" class="text-decoration-none">{{.Name}}</a>
</h2>
<div class="mono text-body-secondary">{{.CIDR}}</div>
</div>
<span class="badge {{badgeFor .Enabled}}">{{statusWord .Enabled}}</span>
</div>
{{if .Description}}
<p class="small text-body-secondary mb-2">{{.Description}}</p>
{{end}}
<div class="mb-3">
<div class="small fw-semibold text-body-secondary mb-1">DNS Policies</div>
{{if .Policies}}
<div class="d-flex flex-wrap gap-1">
{{range .Policies}}
<span class="badge {{if .Enabled}}text-bg-primary{{else}}text-bg-secondary{{end}}">
<i class="bi bi-shield-check me-1"></i>{{.Name}}
<span class="opacity-75">· {{.BlockAction.Label}}</span>
</span>
{{end}}
</div>
{{else}}
<span class="text-body-secondary small fst-italic">
No policies assigned — queries from this network are not filtered.
</span>
{{end}}
</div>
<div class="d-flex gap-2">
<a href="/policies/networks/{{.ID}}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-sliders me-1"></i>Edit policies
</a>
{{template "postform" dict
"Action" (printf "/policies/networks/%d/toggle" .ID) "CSRF" $csrf
"ReturnTo" "/policies/networks"
"Fields" (dict "enabled" (boolstr (not .Enabled)))
"Icon" (toggleIcon .Enabled) "Label" (toggleVerb .Enabled)
"Class" "btn btn-sm btn-outline-secondary"}}
{{template "confirmform" dict
"Action" (printf "/policies/networks/%d/delete" .ID) "CSRF" $csrf
"Icon" "bi-trash" "Label" "Delete"
"Message" (printf "Delete the network %q (%s)? Clients in this range will stop matching any policy." .Name .CIDR)}}
</div>
</div>
</div>
</div>
{{end}}
</div>
{{else if .Data.Search}}
{{template "empty" dict "Icon" "bi-search" "Title" "No networks match that search"
"Message" "Try a different term, or clear the search box."}}
{{else}}
{{template "empty" dict "Icon" "bi-router" "Title" "No client networks yet"
"Message" "A client network binds a subnet to one or more policies. For example, a guest Wi-Fi range on 100.64.30.0/24 might use the adult content, gambling and malware blacklists, while a trusted LAN uses malware only."}}
<div class="text-center pb-3">
<a href="/policies/networks/new" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>Add the first network
</a>
</div>
{{end}}
</div>
</div>
{{end}}
+95
View File
@@ -0,0 +1,95 @@
{{define "content"}}
{{$csrf := .CSRF}}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 class="page-title">Policy Rules</h1>
<p class="text-body-secondary mb-0">
A policy pairs a set of blacklists and allowlists with what to do when a name matches.
</p>
</div>
<a href="/policies/rules/new" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>Add Policy
</a>
</div>
<div class="card">
<div class="card-body">
{{if .Data.Policies}}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Policy</th><th>Blocks</th><th>Allows</th>
<th>Action</th><th class="text-end">Networks</th>
<th>Status</th><th class="row-actions">Actions</th>
</tr>
</thead>
<tbody>
{{range .Data.Policies}}
<tr {{if not .Enabled}}class="is-disabled"{{end}}>
<td>
<a href="/policies/rules/{{.ID}}" class="fw-semibold text-decoration-none">{{.Name}}</a>
{{if .Description}}<div class="small text-body-secondary">{{truncate 70 .Description}}</div>{{end}}
</td>
<td>
{{if .BlacklistName}}
<div class="d-flex flex-wrap gap-1">
{{range .BlacklistName}}
<span class="badge text-bg-danger-subtle border border-danger-subtle text-danger-emphasis">{{.}}</span>
{{end}}
</div>
{{else}}<span class="text-body-secondary small"></span>{{end}}
</td>
<td>
{{if .AllowlistName}}
<div class="d-flex flex-wrap gap-1">
{{range .AllowlistName}}
<span class="badge text-bg-success-subtle border border-success-subtle text-success-emphasis">{{.}}</span>
{{end}}
</div>
{{else}}<span class="text-body-secondary small"></span>{{end}}
</td>
<td>
<span class="badge text-bg-light text-dark">{{.BlockAction.Label}}</span>
{{if eq (printf "%s" .BlockAction) "sinkhole"}}
<div class="small text-body-secondary mono">{{.SinkholeIPv4}} / {{.SinkholeIPv6}}</div>
{{end}}
</td>
<td class="text-end">{{.NetworkCount}}</td>
<td><span class="badge {{badgeFor .Enabled}}">{{statusWord .Enabled}}</span></td>
<td class="row-actions">
<div class="btn-group">
<a href="/policies/rules/{{.ID}}" class="btn btn-sm btn-outline-secondary" title="Edit policy">
<i class="bi bi-pencil"></i>
</a>
{{template "postform" dict
"Action" (printf "/policies/rules/%d/toggle" .ID) "CSRF" $csrf
"ReturnTo" "/policies"
"Fields" (dict "enabled" (boolstr (not .Enabled)))
"Icon" (toggleIcon .Enabled)
"Class" "btn btn-sm btn-outline-secondary"
"Title" (printf "%s this policy" (toggleVerb .Enabled))}}
{{template "confirmform" dict
"Action" (printf "/policies/rules/%d/delete" .ID) "CSRF" $csrf
"Icon" "bi-trash"
"Message" (printf "Delete the policy %q? It will be removed from %d network(s)." .Name .NetworkCount)}}
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
{{template "empty" dict "Icon" "bi-sliders" "Title" "No policies yet"
"Message" "A policy decides what happens when a query matches one of its blacklists — return NXDOMAIN, refuse the query, or answer with a sinkhole address. Assign the policy to a client network to put it into effect."}}
<div class="text-center pb-3">
<a href="/policies/rules/new" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>Create the first policy
</a>
</div>
{{end}}
</div>
</div>
{{end}}
+167
View File
@@ -0,0 +1,167 @@
{{define "content"}}
{{$p := .Data.Policy}}{{$new := .Data.IsNew}}{{$sel := .Data.Selected}}
<nav aria-label="breadcrumb">
<ol class="breadcrumb small">
<li class="breadcrumb-item"><a href="/policies">Policy Rules</a></li>
<li class="breadcrumb-item active" aria-current="page">
{{if $new}}New policy{{else}}{{$p.Name}}{{end}}
</li>
</ol>
</nav>
<h1 class="page-title mb-4">{{if $new}}Create a policy{{else}}Edit {{$p.Name}}{{end}}</h1>
<form method="post" action="{{.Data.FormAction}}">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="row g-3">
<div class="col-12 col-lg-5">
<div class="card mb-3">
<div class="card-header">Policy</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label" for="polName">Name</label>
<input type="text" class="form-control" id="polName" name="name" value="{{$p.Name}}"
placeholder="Guest Wi-Fi Filtering" required>
</div>
<div class="mb-3">
<label class="form-label" for="polDescription">Description</label>
<textarea class="form-control" id="polDescription" name="description" rows="2"
placeholder="What this policy is for">{{$p.Description}}</textarea>
</div>
<div class="form-check form-switch">
<input type="hidden" name="enabled" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="polEnabled"
name="enabled" value="true" {{if or $new $p.Enabled}}checked{{end}}>
<label class="form-check-label" for="polEnabled">Enabled</label>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">When a name is blocked</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label" for="blockAction">Response</label>
<select class="form-select" id="blockAction" name="block_action">
<option value="nxdomain" {{if eq (printf "%s" $p.BlockAction) "nxdomain"}}selected{{end}}>
NXDOMAIN — tell the client the name does not exist
</option>
<option value="refused" {{if eq (printf "%s" $p.BlockAction) "refused"}}selected{{end}}>
REFUSED — tell the client the query was rejected
</option>
<option value="sinkhole" {{if eq (printf "%s" $p.BlockAction) "sinkhole"}}selected{{end}}>
Sinkhole — answer with an address you control
</option>
</select>
<div class="form-text">
NXDOMAIN is the usual choice: clients treat it as a normal negative answer
and stop retrying.
</div>
</div>
<div class="row g-2 mb-3">
<div class="col-12 col-sm-6">
<label class="form-label" for="sink4">Sinkhole IPv4</label>
<input type="text" class="form-control mono" id="sink4" name="sinkhole_ipv4"
value="{{default "0.0.0.0" $p.SinkholeIPv4}}" placeholder="0.0.0.0">
</div>
<div class="col-12 col-sm-6">
<label class="form-label" for="sink6">Sinkhole IPv6</label>
<input type="text" class="form-control mono" id="sink6" name="sinkhole_ipv6"
value="{{default "::" $p.SinkholeIPv6}}" placeholder="::">
</div>
</div>
<div>
<label class="form-label" for="blockTTL">Block response TTL</label>
<input type="number" class="form-control" id="blockTTL" name="block_ttl"
value="{{default 60 $p.BlockTTL}}" min="1" max="86400">
<div class="form-text">
Seconds a client should cache the block. A short value makes list changes
take effect quickly.
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body d-grid gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>{{if $new}}Create policy{{else}}Save changes{{end}}
</button>
<a href="/policies" class="btn btn-outline-secondary">Cancel</a>
</div>
</div>
</div>
<div class="col-12 col-lg-7">
<div class="card mb-3">
<div class="card-header">
<i class="bi bi-shield-slash me-1 text-danger"></i>Blacklists
</div>
<div class="card-body">
<p class="text-body-secondary small">
A query matching any of these lists is blocked using the response chosen on the left.
</p>
{{if .Data.Blacklists}}
<div class="list-group">
{{range .Data.Blacklists}}
<label class="list-group-item d-flex gap-3 align-items-center">
<input class="form-check-input flex-shrink-0" type="checkbox"
name="list_id" value="{{.ID}}" {{if index $sel .ID}}checked{{end}}>
<span class="flex-grow-1">
<span class="fw-semibold">{{.Name}}</span>
{{if not .Enabled}}<span class="badge text-bg-secondary ms-1">disabled</span>{{end}}
<span class="d-block small text-body-secondary">
{{num .DomainCount}} domain{{if ne .DomainCount 1}}s{{end}}
{{if .Description}} · {{truncate 60 .Description}}{{end}}
</span>
</span>
</label>
{{end}}
</div>
{{else}}
<p class="text-body-secondary fst-italic mb-2">No blacklists exist yet.</p>
<a href="/policies/blacklists" class="btn btn-sm btn-outline-primary">Create a blacklist</a>
{{end}}
</div>
</div>
<div class="card">
<div class="card-header">
<i class="bi bi-shield-check me-1 text-success"></i>Allowlists
</div>
<div class="card-body">
<p class="text-body-secondary small">
A name on any allowlist is never blocked, whatever the blacklists say. This is
how you carve an exception out of a large imported list without editing it.
</p>
{{if .Data.Allowlists}}
<div class="list-group">
{{range .Data.Allowlists}}
<label class="list-group-item d-flex gap-3 align-items-center">
<input class="form-check-input flex-shrink-0" type="checkbox"
name="list_id" value="{{.ID}}" {{if index $sel .ID}}checked{{end}}>
<span class="flex-grow-1">
<span class="fw-semibold">{{.Name}}</span>
{{if not .Enabled}}<span class="badge text-bg-secondary ms-1">disabled</span>{{end}}
<span class="d-block small text-body-secondary">
{{num .DomainCount}} domain{{if ne .DomainCount 1}}s{{end}}
{{if .Description}} · {{truncate 60 .Description}}{{end}}
</span>
</span>
</label>
{{end}}
</div>
{{else}}
<p class="text-body-secondary fst-italic mb-2">No allowlists exist yet.</p>
<a href="/policies/allowlists" class="btn btn-sm btn-outline-primary">Create an allowlist</a>
{{end}}
</div>
</div>
</div>
</div>
</form>
{{end}}
+151
View File
@@ -0,0 +1,151 @@
{{define "content"}}
{{$f := .Data.Filter}}{{$csrf := .CSRF}}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 class="page-title">Query Log</h1>
<p class="text-body-secondary mb-0">
Every query this server answered, when logging is enabled.
</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a href="/settings/logging" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-gear me-1"></i>Logging settings
</a>
{{template "confirmform" dict
"Action" "/querylog/clear" "CSRF" $csrf
"Label" "Clear log" "Icon" "bi-trash"
"Class" "btn btn-sm btn-outline-danger"
"Message" "Delete every row in the query log? This cannot be undone."}}
</div>
</div>
{{if not .Data.Enabled}}
<div class="alert alert-warning d-flex align-items-start gap-2">
<i class="bi bi-exclamation-triangle mt-1"></i>
<div>
Query logging is turned off, so no new queries are being recorded.
<a href="/settings/logging" class="alert-link">Turn it on</a> to populate this page and the
dashboard charts.
</div>
</div>
{{end}}
{{if gt .Data.Stats.Dropped 0}}
<div class="alert alert-warning d-flex align-items-start gap-2">
<i class="bi bi-speedometer mt-1"></i>
<div>
{{num .Data.Stats.Dropped}} log entries were dropped because the writer could not keep up.
Query answering is never delayed by logging, so under sustained load some records are
discarded rather than queued.
</div>
</div>
{{end}}
<div class="card">
<div class="card-body">
<form method="get" data-autosubmit class="row g-2 align-items-end mb-3">
<div class="col-12 col-md-3">
<label class="form-label small" for="fDomain">Domain</label>
<input type="search" class="form-control form-control-sm" id="fDomain" name="domain"
value="{{$f.Domain}}" placeholder="example.com">
</div>
<div class="col-6 col-md-2">
<label class="form-label small" for="fClient">Client IP</label>
<input type="search" class="form-control form-control-sm" id="fClient" name="client"
value="{{$f.ClientIP}}" placeholder="192.168.1.10">
</div>
<div class="col-6 col-md-1">
<label class="form-label small" for="fType">Type</label>
<input type="text" class="form-control form-control-sm" id="fType" name="type"
value="{{$f.QType}}" placeholder="A">
</div>
<div class="col-6 col-md-2">
<label class="form-label small" for="fStatus">Status</label>
<select class="form-select form-select-sm" id="fStatus" name="blocked">
<option value="">All</option>
<option value="blocked" {{if eq $f.Blocked "blocked"}}selected{{end}}>Blocked</option>
<option value="allowed" {{if eq $f.Blocked "allowed"}}selected{{end}}>Allowed</option>
</select>
</div>
<div class="col-6 col-md-2">
<label class="form-label small" for="fSource">Source</label>
<select class="form-select form-select-sm" id="fSource" name="source">
<option value="">All</option>
{{range list "authoritative" "cache" "stale" "recursive" "blocked" "refused" "ratelimited" "error"}}
<option value="{{.}}" {{if eq . $f.Source}}selected{{end}}>{{title .}}</option>
{{end}}
</select>
</div>
<div class="col-6 col-md-2">
<label class="form-label small" for="fNetwork">Network</label>
<select class="form-select form-select-sm" id="fNetwork" name="network">
<option value="">All</option>
{{range .Data.Networks}}
<option value="{{.ID}}" {{if eq .ID $f.NetworkID}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
<div class="col-6 col-md-2">
<label class="form-label small" for="fFrom">From</label>
<input type="date" class="form-control form-control-sm" id="fFrom" name="from"
value="{{if not (zeroTime $f.From)}}{{dateOnly $f.From}}{{end}}">
</div>
<div class="col-6 col-md-2">
<label class="form-label small" for="fTo">To</label>
<input type="date" class="form-control form-control-sm" id="fTo" name="to"
value="{{if not (zeroTime $f.To)}}{{dateOnly $f.To}}{{end}}">
</div>
<div class="col-auto">
<noscript><button class="btn btn-sm btn-outline-secondary" type="submit">Apply</button></noscript>
<a href="/querylog" class="btn btn-sm btn-link">Clear filters</a>
</div>
</form>
{{if .Data.Entries}}
<div class="table-responsive">
<table class="table table-hover align-middle table-compact">
<thead>
<tr>
<th>Time</th><th>Client</th><th>Network</th><th>Query</th><th>Type</th>
<th>Result</th><th>Source</th><th>Matched</th><th class="text-end">Took</th>
</tr>
</thead>
<tbody>
{{range .Data.Entries}}
<tr>
<td class="small text-nowrap" title="{{datetime .Timestamp}}">{{timeOnly .Timestamp}}</td>
<td class="mono small">{{.ClientIP}}</td>
<td class="small text-body-secondary">{{default "—" .NetworkName}}</td>
<td class="mono truncate-cell" title="{{.QName}}">{{trimDot .QName}}</td>
<td><span class="type-chip {{typeBadge .QType}}">{{.QType}}</span></td>
<td>
<span class="badge {{rcodeBadge .Rcode}}">{{.Rcode}}</span>
{{if .Blocked}}<span class="badge text-bg-danger ms-1">blocked</span>{{end}}
</td>
<td><span class="badge {{sourceBadge .Source}}">{{.Source}}</span></td>
<td class="small">
{{if .Blocked}}
<span class="text-body-secondary">{{.BlacklistName}}</span>
{{if .MatchedRule}}<div class="mono text-body-secondary">{{.MatchedRule}}</div>{{end}}
{{else if .CacheHit}}
<span class="text-body-secondary">cache hit</span>
{{else}}
<span class="text-body-secondary"></span>
{{end}}
</td>
<td class="text-end small">{{ms .DurationMS}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{template "pagination" dict "P" .Data.Pagination "Q" .Query}}
{{else}}
{{template "empty" dict "Icon" "bi-journal-text" "Title" "No queries match"
"Message" "Either nothing has been logged yet, or the filters above exclude everything. Clear the filters to see the whole log."}}
{{end}}
</div>
</div>
{{end}}
+368
View File
@@ -0,0 +1,368 @@
{{define "content"}}
{{$z := .Data.Zone}}{{$csrf := .CSRF}}{{$f := .Data.Filter}}
{{$backTo := printf "/zones/%d" $z.ID}}
<nav aria-label="breadcrumb">
<ol class="breadcrumb small">
<li class="breadcrumb-item">
<a href="{{if eq (printf "%s" $z.Kind) "forward"}}/zones{{else}}/zones/reverse{{end}}">
{{$z.Kind.Label}} Zones
</a>
</li>
<li class="breadcrumb-item active" aria-current="page">{{trimDot $z.Name}}</li>
</ol>
</nav>
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h1 class="page-title mono">{{trimDot $z.Name}}</h1>
<p class="text-body-secondary mb-0">
{{if $z.Description}}{{$z.Description}} · {{end}}
serial {{$z.Serial}} · default TTL {{$z.DefaultTTL}}s ·
<span class="badge {{badgeFor $z.Enabled}}">{{statusWord $z.Enabled}}</span>
</p>
</div>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#recordModal"
data-record-new data-record-action="/zones/{{$z.ID}}/records">
<i class="bi bi-plus-lg me-1"></i>Add Record
</button>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="bi bi-three-dots"></i> Zone
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/zones/{{$z.ID}}/edit"><i class="bi bi-pencil me-2"></i>Edit zone</a></li>
<li><a class="dropdown-item" href="/zones/{{$z.ID}}/export"><i class="bi bi-download me-2"></i>Export zone file</a></li>
<li><button class="dropdown-item" type="button" data-bs-toggle="modal" data-bs-target="#importModal">
<i class="bi bi-upload me-2"></i>Import zone file</button></li>
<li><button class="dropdown-item" type="button" data-bs-toggle="modal" data-bs-target="#cloneModal">
<i class="bi bi-copy me-2"></i>Clone zone</button></li>
</ul>
</div>
</div>
</div>
{{if .Data.Problems}}
<div class="alert alert-warning">
<div class="fw-semibold mb-2">
<i class="bi bi-exclamation-triangle me-1"></i>
{{len .Data.Problems}} record{{if ne (len .Data.Problems) 1}}s{{end}} in this zone could not be loaded and are not being served
</div>
<ul class="mb-0 small">
{{range .Data.Problems}}
<li><span class="mono">{{.Name}} {{.Type}}</span> — {{.Err}}</li>
{{end}}
</ul>
</div>
{{end}}
<div class="card" data-bulk-scope>
<div class="card-body">
<form method="get" data-autosubmit class="row g-2 align-items-center mb-3">
<div class="col-12 col-md-5 col-lg-4">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" name="q" value="{{$f.Search}}"
placeholder="Search records..." aria-label="Search records">
</div>
</div>
<div class="col-6 col-md-3 col-lg-2">
<select class="form-select form-select-sm" name="type" aria-label="Filter by record type">
<option value="">All types</option>
{{range .Data.TypesInUse}}
<option value="{{.}}" {{if eq . $f.Type}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
<div class="col-6 col-md-3 col-lg-2">
<select class="form-select form-select-sm" name="status" aria-label="Filter by status">
<option value="">All statuses</option>
<option value="enabled" {{if eq $f.Enabled "enabled"}}selected{{end}}>Enabled</option>
<option value="disabled" {{if eq $f.Enabled "disabled"}}selected{{end}}>Disabled</option>
</select>
</div>
<div class="col-auto">
<noscript><button class="btn btn-sm btn-outline-secondary" type="submit">Apply</button></noscript>
{{if or $f.Search $f.Type $f.Enabled}}
<a href="{{$backTo}}" class="btn btn-sm btn-link">Clear</a>
{{end}}
</div>
</form>
<form method="post" action="/zones/{{$z.ID}}/records/bulk" id="bulkForm">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<input type="hidden" name="return_to" value="{{$backTo}}">
<div class="bulk-bar" data-bulk-bar>
<span class="fw-semibold"><span data-bulk-count>0</span> selected</span>
<div class="d-flex gap-2 ms-auto">
<button type="submit" name="action" value="enable" class="btn btn-sm btn-outline-success">
<i class="bi bi-check-lg me-1"></i>Enable
</button>
<button type="submit" name="action" value="disable" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-slash-circle me-1"></i>Disable
</button>
<button type="submit" name="action" value="delete" class="btn btn-sm btn-outline-danger js-confirm-bulk"
data-confirm="Delete the selected records? This cannot be undone.">
<i class="bi bi-trash me-1"></i>Delete
</button>
</div>
</div>
{{if .Data.Records}}
<div class="table-responsive">
<table class="table table-hover align-middle table-compact">
<thead>
<tr>
<th style="width:2.5rem;">
<input class="form-check-input" type="checkbox" data-bulk-all aria-label="Select all records">
</th>
<th>Name</th>
<th>Type</th>
<th>Value</th>
<th class="text-end">TTL</th>
<th>Status</th>
<th class="row-actions">Actions</th>
</tr>
</thead>
<tbody>
{{range .Data.Records}}
<tr {{if not .Enabled}}class="is-disabled"{{end}}>
<td>
<input class="form-check-input" type="checkbox" name="record_id" value="{{.ID}}"
data-bulk-item aria-label="Select record {{.Name}} {{.Type}}">
</td>
<td class="mono fw-semibold">{{.Name}}</td>
<td><span class="type-chip {{typeBadge .Type}}">{{.Type}}</span></td>
<td class="mono truncate-cell" title="{{.Data}}">{{.Data}}</td>
<td class="text-end">
{{if .TTL}}{{.TTL}}{{else}}<span class="text-body-secondary" title="Inherited from the zone">{{$z.DefaultTTL}}</span>{{end}}
</td>
<td><span class="badge {{badgeFor .Enabled}}">{{statusWord .Enabled}}</span></td>
<td class="row-actions">
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-secondary"
data-bs-toggle="modal" data-bs-target="#recordModal"
data-record-action="/records/{{.ID}}/edit"
data-record-name="{{.Name}}"
data-record-rtype="{{.Type}}"
data-record-data="{{.Data}}"
data-record-ttl="{{if .TTL}}{{.TTL}}{{end}}"
data-record-comment="{{.Comment}}"
data-record-enabled="{{.Enabled}}"
data-record-values="{{json (rdataFields .Type .Data)}}"
title="Edit record">
<i class="bi bi-pencil"></i>
</button>
{{template "postform" dict
"Action" (printf "/records/%d/toggle" .ID) "CSRF" $csrf "ReturnTo" $backTo
"Fields" (dict "enabled" (boolstr (not .Enabled)))
"Icon" (toggleIcon .Enabled)
"Class" "btn btn-sm btn-outline-secondary"
"Title" (printf "%s this record" (toggleVerb .Enabled))}}
{{template "confirmform" dict
"Action" (printf "/records/%d/delete" .ID) "CSRF" $csrf "ReturnTo" $backTo
"Icon" "bi-trash"
"Message" (printf "Delete the %s record for %s?" .Type .Name)}}
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{template "pagination" dict "P" .Data.Pagination "Q" .Query}}
{{else if or $f.Search $f.Type $f.Enabled}}
{{template "empty" dict "Icon" "bi-search" "Title" "No records match those filters"
"Message" "Adjust or clear the filters above to see the rest of the zone."}}
{{else}}
{{template "empty" dict "Icon" "bi-list-columns-reverse" "Title" "This zone has no records yet"
"Message" "A zone needs at least an address record to be useful. The SOA and apex NS records are maintained for you automatically."}}
<div class="text-center pb-3">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#recordModal"
data-record-new data-record-action="/zones/{{$z.ID}}/records">
<i class="bi bi-plus-lg me-1"></i>Add the first record
</button>
</div>
{{end}}
</form>
</div>
</div>
{{/* ---- Record editor modal ---- */}}
<div class="modal fade" id="recordModal" tabindex="-1" aria-hidden="true" aria-labelledby="recordModalLabel">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<form method="post" id="recordForm" action="/zones/{{$z.ID}}/records">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<input type="hidden" name="return_to" value="{{$backTo}}">
<div class="modal-header">
<h5 class="modal-title" id="recordModalLabel">Add record</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="recordEditor"
data-types="{{json .Data.RecordTypes}}" data-values="{}">
<div class="row g-3 mb-3">
<div class="col-12 col-md-6">
<label class="form-label" for="recordName">Name</label>
<div class="input-group">
<input type="text" class="form-control mono" id="recordName" name="name"
value="@" placeholder="@" required autocomplete="off">
<span class="input-group-text mono">.{{trimDot $z.Name}}</span>
</div>
<div class="form-text">Use <code>@</code> for the zone apex, or <code>*</code> for a wildcard.</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="recordType">Type</label>
<select class="form-select" id="recordType" name="type" data-record-type>
<optgroup label="Common">
{{range .Data.CommonTypes}}
<option value="{{.Type}}">{{.Type}} — {{.Description}}</option>
{{end}}
</optgroup>
<optgroup label="All types">
{{range .Data.RecordTypes}}
{{if ne .Type "RAW"}}
<option value="{{.Type}}">{{.Type}} — {{.Description}}</option>
{{end}}
{{end}}
</optgroup>
</select>
</div>
</div>
<div class="row g-3" data-record-fields></div>
<div class="d-none" data-record-raw>
<label class="form-label" for="recordData">Record data</label>
<textarea class="form-control mono" id="recordData" name="data" rows="3"
placeholder="Rdata exactly as it appears in a zone file"></textarea>
<div class="form-text">
Enter the record data by hand. Unknown types use the RFC 3597 form,
for example <code>\# 4 0A0B0C0D</code>.
</div>
</div>
<div class="form-check form-switch mt-3">
<input class="form-check-input" type="checkbox" role="switch" id="recordAdvanced"
name="advanced" value="true" data-record-advanced>
<label class="form-check-label" for="recordAdvanced">
Advanced: enter record data by hand
</label>
</div>
<hr>
<div class="row g-3">
<div class="col-6 col-md-4">
<label class="form-label" for="recordTTL">TTL</label>
<input type="number" class="form-control" id="recordTTL" name="ttl"
placeholder="{{$z.DefaultTTL}}" min="1" max="604800">
<div class="form-text">Blank inherits the zone default.</div>
</div>
<div class="col-6 col-md-8">
<label class="form-label" for="recordComment">Comment</label>
<input type="text" class="form-control" id="recordComment" name="comment"
placeholder="Optional note for other administrators">
</div>
</div>
<div class="form-check form-switch mt-3">
<input type="hidden" name="enabled" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="recordEnabled"
name="enabled" value="true" checked>
<label class="form-check-label" for="recordEnabled">Enabled</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save record</button>
</div>
</form>
</div>
</div>
</div>
{{/* ---- Import modal ---- */}}
<div class="modal fade" id="importModal" tabindex="-1" aria-hidden="true" aria-labelledby="importModalLabel">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form method="post" action="/zones/{{$z.ID}}/import" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<div class="modal-header">
<h5 class="modal-title" id="importModalLabel">Import a BIND zone file</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label" for="zoneFile">Zone file</label>
<input type="file" class="form-control" id="zoneFile" name="file" accept=".zone,.txt,.db,text/plain">
</div>
<div class="mb-3">
<label class="form-label" for="zonePaste">…or paste its contents</label>
<textarea class="form-control mono" id="zonePaste" name="content" rows="8"
placeholder="$ORIGIN {{$z.Name}}&#10;$TTL 3600&#10;@ IN A 192.0.2.10"></textarea>
</div>
<div class="mb-2">
<label class="form-label" for="importMode">If the zone already has records</label>
<select class="form-select" id="importMode" name="mode">
<option value="merge">Merge — keep existing records and add the imported ones</option>
<option value="replace">Replace — delete every existing record first</option>
</select>
</div>
<p class="form-text mb-0">
The whole file is checked before anything is written, so a syntax error
never leaves the zone half-imported. The SOA is read for its timers;
serial management stays with this server.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Import</button>
</div>
</form>
</div>
</div>
</div>
{{/* ---- Clone modal ---- */}}
<div class="modal fade" id="cloneModal" tabindex="-1" aria-hidden="true" aria-labelledby="cloneModalLabel">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="/zones/{{$z.ID}}/clone">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<div class="modal-header">
<h5 class="modal-title" id="cloneModalLabel">Clone {{trimDot $z.Name}}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label" for="cloneName">New zone name</label>
<input type="text" class="form-control mono" id="cloneName" name="name"
placeholder="example.net" required autocomplete="off">
</div>
<div class="mb-3">
<label class="form-label" for="cloneDescription">Description</label>
<input type="text" class="form-control" id="cloneDescription" name="description"
value="Cloned from {{trimDot $z.Name}}">
</div>
<p class="form-text mb-0">
Every record is copied. References to <span class="mono">{{trimDot $z.Name}}</span>
inside record data are rewritten to the new name so the clone is self-consistent.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Clone zone</button>
</div>
</form>
</div>
</div>
</div>
{{end}}
+107
View File
@@ -0,0 +1,107 @@
{{define "content"}}
{{$csrf := .CSRF}}{{$f := .Data.Filter}}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 class="page-title">Records</h1>
<p class="text-body-secondary mb-0">Search every record across all zones.</p>
</div>
</div>
<div class="card">
<div class="card-body">
<form method="get" data-autosubmit class="row g-2 align-items-center mb-3">
<div class="col-12 col-md-5 col-lg-4">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" name="q" value="{{$f.Search}}"
placeholder="Search names, values and comments..." aria-label="Search records">
</div>
</div>
<div class="col-6 col-md-3 col-lg-3">
<select class="form-select form-select-sm" name="zone" aria-label="Filter by zone">
<option value="">All zones</option>
{{range .Data.Zones}}
<option value="{{.ID}}" {{if eq .ID $f.ZoneID}}selected{{end}}>{{trimDot .Name}}</option>
{{end}}
</select>
</div>
<div class="col-6 col-md-2 col-lg-2">
<select class="form-select form-select-sm" name="type" aria-label="Filter by type">
<option value="">All types</option>
{{range .Data.TypesInUse}}
<option value="{{.}}" {{if eq . $f.Type}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
<div class="col-6 col-md-2 col-lg-2">
<select class="form-select form-select-sm" name="status" aria-label="Filter by status">
<option value="">All statuses</option>
<option value="enabled" {{if eq $f.Enabled "enabled"}}selected{{end}}>Enabled</option>
<option value="disabled" {{if eq $f.Enabled "disabled"}}selected{{end}}>Disabled</option>
</select>
</div>
<div class="col-auto">
<noscript><button class="btn btn-sm btn-outline-secondary" type="submit">Apply</button></noscript>
{{if or $f.Search $f.Type $f.Enabled $f.ZoneID}}
<a href="/records" class="btn btn-sm btn-link">Clear</a>
{{end}}
</div>
</form>
{{if .Data.Records}}
<div class="table-responsive">
<table class="table table-hover align-middle table-compact">
<thead>
<tr>
<th>Zone</th><th>Name</th><th>Type</th><th>Value</th>
<th class="text-end">TTL</th><th>Status</th><th class="row-actions">Actions</th>
</tr>
</thead>
<tbody>
{{range .Data.Records}}
<tr {{if not .Enabled}}class="is-disabled"{{end}}>
<td>
<a href="/zones/{{.ZoneID}}" class="text-decoration-none mono small">{{trimDot .ZoneName}}</a>
</td>
<td class="mono fw-semibold">{{.Name}}</td>
<td><span class="type-chip {{typeBadge .Type}}">{{.Type}}</span></td>
<td class="mono truncate-cell" title="{{.Data}}">{{.Data}}</td>
<td class="text-end">{{if .TTL}}{{.TTL}}{{else}}<span class="text-body-secondary"></span>{{end}}</td>
<td><span class="badge {{badgeFor .Enabled}}">{{statusWord .Enabled}}</span></td>
<td class="row-actions">
<div class="btn-group">
<a href="/zones/{{.ZoneID}}?q={{.Name}}" class="btn btn-sm btn-outline-secondary" title="Open in zone">
<i class="bi bi-box-arrow-up-right"></i>
</a>
{{template "postform" dict
"Action" (printf "/records/%d/toggle" .ID) "CSRF" $csrf "ReturnTo" "/records"
"Fields" (dict "enabled" (boolstr (not .Enabled)))
"Icon" (toggleIcon .Enabled)
"Class" "btn btn-sm btn-outline-secondary"
"Title" (printf "%s this record" (toggleVerb .Enabled))}}
{{template "confirmform" dict
"Action" (printf "/records/%d/delete" .ID) "CSRF" $csrf "ReturnTo" "/records"
"Icon" "bi-trash"
"Message" (printf "Delete the %s record for %s in %s?" .Type .Name (trimDot .ZoneName))}}
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{template "pagination" dict "P" .Data.Pagination "Q" .Query}}
{{else if or $f.Search $f.Type $f.Enabled $f.ZoneID}}
{{template "empty" dict "Icon" "bi-search" "Title" "No records match those filters"
"Message" "Adjust or clear the filters above."}}
{{else}}
{{template "empty" dict "Icon" "bi-list-columns-reverse" "Title" "No records yet"
"Message" "Create a zone and add records to it, and they will all be searchable from here."}}
<div class="text-center pb-3">
<a href="/zones" class="btn btn-primary"><i class="bi bi-diagram-3 me-1"></i>Go to zones</a>
</div>
{{end}}
</div>
</div>
{{end}}
+148
View File
@@ -0,0 +1,148 @@
{{define "content"}}
{{$s := .Data.Settings}}{{$st := .Data.Stats}}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 class="page-title">Resolver</h1>
<p class="text-body-secondary mb-0">
Recursive resolution is performed by forwarding to the upstream servers below.
</p>
</div>
<a href="/settings/resolver" class="btn btn-primary btn-sm">
<i class="bi bi-gear me-1"></i>Resolver settings
</a>
</div>
{{if not $s.DNS.Recursion}}
<div class="alert alert-secondary d-flex align-items-center gap-2">
<i class="bi bi-info-circle"></i>
<div>
Recursion is turned off. This server answers only for its own authoritative zones;
every other query is refused.
</div>
</div>
{{end}}
<div class="row g-3 mb-4">
{{template "stat" dict "Label" "Forwarded queries" "Value" (num $st.Queries) "Icon" "bi-globe2"}}
{{template "stat" dict "Label" "Failures" "Value" (num $st.Failures) "Icon" "bi-exclamation-triangle" "Tone" "danger"}}
{{template "stat" dict "Label" "Average latency" "Value" (ms $st.AvgLatencyMS) "Icon" "bi-stopwatch"}}
{{template "stat" dict "Label" "Healthy upstreams" "Value" (printf "%d / %d" $st.Healthy $st.Upstreams) "Icon" "bi-heart-pulse" "Tone" "success"}}
</div>
<div class="row g-3">
<div class="col-12 col-xl-7">
<div class="card h-100">
<div class="card-header">Upstream servers</div>
{{if .Data.Upstreams}}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Server</th><th>Status</th>
<th class="text-end">Latency</th><th class="text-end">Queries</th>
<th class="text-end">Failures</th><th>Last used</th>
</tr>
</thead>
<tbody>
{{range .Data.Upstreams}}
<tr>
<td class="mono fw-semibold">{{.Address}}</td>
<td>
{{if .Healthy}}
<span class="badge text-bg-success">healthy</span>
{{else}}
<span class="badge text-bg-danger">resting</span>
{{end}}
{{if .LastError}}
<div class="small text-body-secondary truncate-cell" title="{{.LastError}}">{{truncate 60 .LastError}}</div>
{{end}}
</td>
<td class="text-end">{{ms .LatencyMS}}</td>
<td class="text-end">{{num .Queries}}</td>
<td class="text-end">{{num .Failures}}</td>
<td class="small text-body-secondary">
{{if .LastUsed}}{{timeAgo .LastUsed}}{{else}}never{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="card-body pt-2">
<p class="form-text mb-0">
A server that fails three times in a row is rested for twenty seconds and moved
to the back of the selection order. The current strategy is
<span class="badge text-bg-light text-dark">{{$s.Resolver.Strategy}}</span>.
</p>
</div>
{{else}}
<div class="card-body">
{{template "empty" dict "Icon" "bi-globe2" "Title" "No upstream resolvers configured"
"Message" "Recursive resolution needs at least one upstream server. Add one in the resolver settings."}}
</div>
{{end}}
</div>
</div>
<div class="col-12 col-xl-5">
<div class="card mb-3">
<div class="card-header">Test an upstream</div>
<div class="card-body">
<form method="post" action="/resolver/test" class="row g-2">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="col-12">
<label class="form-label" for="testAddress">Server</label>
<input type="text" class="form-control form-control-sm mono" id="testAddress" name="address"
placeholder="1.1.1.1:53" required
value="{{if .Data.Upstreams}}{{(index .Data.Upstreams 0).Address}}{{end}}">
</div>
<div class="col-12">
<label class="form-label" for="testName">Name to resolve</label>
<input type="text" class="form-control form-control-sm mono" id="testName" name="name"
placeholder="example.com" value="example.com">
</div>
<div class="col-12 d-grid">
<button type="submit" class="btn btn-outline-primary btn-sm">
<i class="bi bi-broadcast me-1"></i>Send a test query
</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header">Recursion access control</div>
<div class="card-body">
<p class="form-text">
Recursion is refused for any client not listed here. This is what keeps the
server from becoming an open resolver.
</p>
<div class="mb-3">
<div class="fw-semibold small mb-1">Allowed networks</div>
{{if $s.Resolver.AllowNetworks}}
<div class="d-flex flex-wrap gap-1">
{{range $s.Resolver.AllowNetworks}}
<span class="badge text-bg-success-subtle border border-success-subtle text-success-emphasis mono">{{.}}</span>
{{end}}
</div>
{{else}}
<div class="text-danger small">None — recursion is denied to every client.</div>
{{end}}
</div>
{{if $s.Resolver.DenyNetworks}}
<div>
<div class="fw-semibold small mb-1">Denied networks</div>
<div class="d-flex flex-wrap gap-1">
{{range $s.Resolver.DenyNetworks}}
<span class="badge text-bg-danger-subtle border border-danger-subtle text-danger-emphasis mono">{{.}}</span>
{{end}}
</div>
<div class="form-text">Denies are evaluated before allows.</div>
</div>
{{end}}
</div>
</div>
</div>
</div>
{{end}}
+162
View File
@@ -0,0 +1,162 @@
{{define "content"}}
{{$csrf := .CSRF}}
<h1 class="page-title mb-1">Settings</h1>
<p class="text-body-secondary mb-4">Server configuration, stored in the database.</p>
{{template "settingsnav" .}}
<div class="row g-3">
<div class="col-12 col-xl-8">
<div class="card mb-3">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<span>API tokens</span>
<button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#newTokenModal">
<i class="bi bi-plus-lg me-1"></i>Create token
</button>
</div>
{{if .Data.Tokens}}
<div class="table-responsive">
<table class="table table-hover align-middle table-compact">
<thead>
<tr>
<th>Name</th><th>Prefix</th><th>Created</th>
<th>Last used</th><th>Status</th><th class="row-actions">Actions</th>
</tr>
</thead>
<tbody>
{{range .Data.Tokens}}
<tr {{if not .Enabled}}class="is-disabled"{{end}}>
<td>
<span class="fw-semibold">{{.Name}}</span>
{{if .Description}}<div class="small text-body-secondary">{{truncate 70 .Description}}</div>{{end}}
</td>
<td class="mono small">vibedns_{{.Prefix}}…</td>
<td class="small text-body-secondary">{{timeAgo .CreatedAt}}</td>
<td class="small text-body-secondary">
{{if .LastUsedAt}}{{timeAgo .LastUsedAt}}{{else}}never{{end}}
</td>
<td><span class="badge {{badgeFor .Enabled}}">{{statusWord .Enabled}}</span></td>
<td class="row-actions">
<div class="btn-group">
{{template "postform" dict
"Action" (printf "/settings/api/tokens/%d/toggle" .ID) "CSRF" $csrf
"Fields" (dict "enabled" (boolstr (not .Enabled)))
"Icon" (toggleIcon .Enabled)
"Class" "btn btn-sm btn-outline-secondary"
"Title" (printf "%s this token" (toggleVerb .Enabled))}}
{{template "confirmform" dict
"Action" (printf "/settings/api/tokens/%d/delete" .ID) "CSRF" $csrf
"Icon" "bi-trash" "Title" "Revoke permanently"
"Message" (printf "Revoke the token %q? Any automation using it will stop working immediately." .Name)}}
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="card-body">
{{template "empty" dict "Icon" "bi-key" "Title" "No API tokens yet"
"Message" "A token lets a script use the REST API without the administrator password. Tokens are stored hashed, shown only once when created, and can be revoked individually."}}
<div class="text-center">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newTokenModal">
<i class="bi bi-plus-lg me-1"></i>Create the first token
</button>
</div>
</div>
{{end}}
</div>
<div class="card">
<div class="card-header">Using the API</div>
<div class="card-body">
<p class="text-body-secondary small">
Every resource lives under <span class="mono">/api/v1</span>. Authenticate with a
bearer token, or with the administrator's HTTP Basic credentials.
</p>
<div class="mb-3">
<div class="small fw-semibold mb-1">List zones</div>
<pre class="answer-block" id="exampleList">curl -H "Authorization: Bearer $VIBEDNS_TOKEN" \
{{default "http://127.0.0.1:8080" .Data.BaseURL}}/api/v1/zones</pre>
</div>
<div class="mb-3">
<div class="small fw-semibold mb-1">Add an A record</div>
<pre class="answer-block">curl -X POST -H "Authorization: Bearer $VIBEDNS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"www","type":"A","data":"192.0.2.10","ttl":3600}' \
{{default "http://127.0.0.1:8080" .Data.BaseURL}}/api/v1/zones/1/records</pre>
</div>
<div>
<div class="small fw-semibold mb-1">Import a blocklist</div>
<pre class="answer-block mb-0">curl -X POST -H "Authorization: Bearer $VIBEDNS_TOKEN" \
--data-binary @hosts.txt \
{{default "http://127.0.0.1:8080" .Data.BaseURL}}/api/v1/blacklists/1/import</pre>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card">
<div class="card-header">How tokens are stored</div>
<div class="card-body small text-body-secondary">
<p>
A token is 256 bits from the system random source. Only a short prefix — enough to
find the right row — and a SHA-256 hash are stored; the token itself is shown once,
at creation, and cannot be recovered afterwards.
</p>
<p>
A fast hash is appropriate here precisely because a token is not a human-chosen
password: there is no small guess space to search, and the hash is verified on
every API request.
</p>
<p class="mb-0">
Tokens carry the same authority as the administrator over the API, but cannot be
used to sign in to this interface or to change the administrator's credentials.
</p>
</div>
</div>
</div>
</div>
<div class="modal fade" id="newTokenModal" tabindex="-1" aria-hidden="true" aria-labelledby="newTokenModalLabel">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="/settings/api/tokens">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<div class="modal-header">
<h5 class="modal-title" id="newTokenModalLabel">Create an API token</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label" for="tokenName">Name</label>
<input type="text" class="form-control" id="tokenName" name="name" required
placeholder="Ansible deployment">
</div>
<div class="mb-3">
<label class="form-label" for="tokenDescription">Description</label>
<textarea class="form-control" id="tokenDescription" name="description" rows="2"
placeholder="What uses this token, and from where"></textarea>
</div>
<div class="alert alert-warning small mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>
The token is displayed once, immediately after creation. Copy it then — it cannot
be shown again.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Create token</button>
</div>
</form>
</div>
</div>
</div>
{{end}}
+168
View File
@@ -0,0 +1,168 @@
{{define "content"}}
{{$s := .Data.S}}{{$st := .Data.Stats}}
<h1 class="page-title mb-1">Settings</h1>
<p class="text-body-secondary mb-4">Server configuration, stored in the database.</p>
{{template "settingsnav" .}}
<form method="post" action="/settings/cache">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="row g-3">
<div class="col-12 col-xl-8">
<div class="card">
<div class="card-header">Resolver cache</div>
<div class="card-body">
<div class="form-section">
<div class="form-check form-switch mb-2">
<input type="hidden" name="enabled" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="cacheEnabled"
name="enabled" value="true" {{if $s.Cache.Enabled}}checked{{end}}>
<label class="form-check-label" for="cacheEnabled">Enable the resolver cache</label>
</div>
<div class="form-text">
Turning the cache off flushes it immediately and sends every recursive query
upstream.
</div>
</div>
<div class="form-section">
<div class="form-section-title">Size</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label" for="maxEntries">Maximum entries</label>
<input type="number" class="form-control" id="maxEntries" name="max_entries"
value="{{$s.Cache.MaxEntries}}" min="100" max="10000000">
<div class="form-text">
Least recently used entries are evicted past this limit. Currently holding
{{num $st.Entries}} entries using roughly {{bytes $st.Bytes}}.
</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="cleanupSeconds">Cleanup interval</label>
<input type="number" class="form-control" id="cleanupSeconds" name="cleanup_seconds"
value="{{$s.Cache.CleanupSeconds}}" min="5" max="3600">
<div class="form-text">
Seconds between sweeps that drop fully expired entries.
</div>
</div>
</div>
</div>
<div class="form-section">
<div class="form-section-title">Time to live</div>
<div class="row g-3">
<div class="col-6 col-md-4">
<label class="form-label" for="minTTL">Minimum TTL</label>
<input type="number" class="form-control" id="minTTL" name="min_ttl"
value="{{$s.Cache.MinTTL}}" min="0" max="86400">
<div class="form-text">Raises very short TTLs</div>
</div>
<div class="col-6 col-md-4">
<label class="form-label" for="maxTTL">Maximum TTL</label>
<input type="number" class="form-control" id="maxTTL" name="max_ttl"
value="{{$s.Cache.MaxTTL}}" min="1" max="604800">
<div class="form-text">Caps very long TTLs</div>
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="negativeTTL">Negative cache TTL</label>
<input type="number" class="form-control" id="negativeTTL" name="negative_ttl"
value="{{$s.Cache.NegativeTTL}}" min="0" max="86400">
<div class="form-text">
How long NXDOMAIN and empty answers are remembered, capped by the SOA
minimum in the response.
</div>
</div>
</div>
</div>
<div class="form-section">
<div class="form-section-title">Stale answers</div>
<div class="form-check form-switch mb-3">
<input type="hidden" name="serve_stale" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="serveStale"
name="serve_stale" value="true" {{if $s.Cache.ServeStale}}checked{{end}}>
<label class="form-check-label" for="serveStale">
Serve expired answers while refreshing them
</label>
</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label" for="staleTTL">Stale window</label>
<input type="number" class="form-control" id="staleTTL" name="stale_ttl"
value="{{$s.Cache.StaleTTL}}" min="0" max="604800">
<div class="form-text">
Seconds past expiry an answer may still be used. This keeps names
resolving when an upstream is briefly unreachable, rather than failing
the client outright.
</div>
</div>
</div>
</div>
<div class="form-section">
<div class="form-section-title">Prefetch</div>
<div class="form-check form-switch mb-3">
<input type="hidden" name="prefetch" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="prefetch"
name="prefetch" value="true" {{if $s.Cache.Prefetch}}checked{{end}}>
<label class="form-check-label" for="prefetch">
Refresh popular entries before they expire
</label>
</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label" for="prefetchPct">Refresh threshold</label>
<div class="input-group">
<input type="number" class="form-control" id="prefetchPct" name="prefetch_pct"
value="{{$s.Cache.PrefetchPercent}}" min="1" max="90">
<span class="input-group-text">%</span>
</div>
<div class="form-text">
An entry hit with less than this share of its TTL remaining is refreshed
in the background, so the client never waits on the upstream.
</div>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save cache settings
</button>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Current cache</span>
<a href="/cache" class="btn btn-sm btn-outline-secondary">Browse</a>
</div>
<div class="card-body">
<dl class="row small mb-0">
<dt class="col-7 text-body-secondary">Entries</dt>
<dd class="col-5 text-end fw-semibold">{{num $st.Entries}}</dd>
<dt class="col-7 text-body-secondary">Hit rate</dt>
<dd class="col-5 text-end fw-semibold">{{pct $st.HitRate}}</dd>
<dt class="col-7 text-body-secondary">Hits</dt>
<dd class="col-5 text-end">{{num $st.Hits}}</dd>
<dt class="col-7 text-body-secondary">Misses</dt>
<dd class="col-5 text-end">{{num $st.Misses}}</dd>
<dt class="col-7 text-body-secondary">Served stale</dt>
<dd class="col-5 text-end">{{num $st.StaleHits}}</dd>
<dt class="col-7 text-body-secondary">Evictions</dt>
<dd class="col-5 text-end">{{num $st.Evictions}}</dd>
<dt class="col-7 text-body-secondary">Memory</dt>
<dd class="col-5 text-end">{{bytes $st.Bytes}}</dd>
</dl>
</div>
</div>
</div>
</div>
</form>
{{end}}
+275
View File
@@ -0,0 +1,275 @@
{{define "content"}}
{{$s := .Data.S}}{{$db := .Data.DBStats}}{{$csrf := .CSRF}}{{$bs := .Data.BackupStatus}}
<h1 class="page-title mb-1">Settings</h1>
<p class="text-body-secondary mb-4">Server configuration, stored in the database.</p>
{{template "settingsnav" .}}
{{if .Data.PendingRestore}}
<div class="alert alert-danger d-flex flex-wrap align-items-center gap-3">
<i class="bi bi-exclamation-octagon fs-4"></i>
<div class="flex-grow-1">
<div class="fw-semibold">A database restore is staged</div>
<div class="small">
The next time this server starts, the live database will be replaced by the staged
backup. The current database is preserved alongside it, so this remains reversible.
</div>
</div>
{{template "confirmform" dict
"Action" "/settings/database/restore/cancel" "CSRF" $csrf
"Label" "Cancel restore" "Icon" "bi-x-lg"
"Class" "btn btn-sm btn-outline-dark"
"Message" "Discard the staged restore and keep the current database?"}}
</div>
{{end}}
<div class="row g-3">
<div class="col-12 col-xl-4">
<div class="card mb-3">
<div class="card-header">Database</div>
<div class="card-body">
<dl class="row small mb-0">
<dt class="col-5 text-body-secondary">Path</dt>
<dd class="col-7 mono text-break">{{$db.Path}}</dd>
<dt class="col-5 text-body-secondary">Size</dt>
<dd class="col-7">{{bytes $db.SizeBytes}}</dd>
<dt class="col-5 text-body-secondary">Write-ahead log</dt>
<dd class="col-7">{{bytes $db.WALBytes}}</dd>
<dt class="col-5 text-body-secondary">Schema version</dt>
<dd class="col-7">{{$db.SchemaVer}}</dd>
<dt class="col-5 text-body-secondary">Zones</dt>
<dd class="col-7">{{num $db.Zones}}</dd>
<dt class="col-5 text-body-secondary">Records</dt>
<dd class="col-7">{{num $db.Records}}</dd>
<dt class="col-5 text-body-secondary">List domains</dt>
<dd class="col-7">{{num $db.Domains}}</dd>
<dt class="col-5 text-body-secondary">Query log rows</dt>
<dd class="col-7">{{num $db.QueryLogs}}</dd>
<dt class="col-5 text-body-secondary">Audit rows</dt>
<dd class="col-7">{{num $db.AuditLogs}}</dd>
</dl>
</div>
</div>
<div class="card mb-3">
<div class="card-header">Schema migrations</div>
<div class="card-body p-0">
<table class="table table-sm table-compact mb-0">
<tbody>
{{range .Data.Migrations}}
<tr>
<td class="mono small">{{.Version}}_{{.Name}}</td>
<td class="text-end">
{{if .Drifted}}<span class="badge text-bg-danger" title="The file changed after being applied">drift</span>
{{else if .Applied}}<span class="badge text-bg-success">applied</span>
{{else}}<span class="badge text-bg-warning">pending</span>{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">Configuration transfer</div>
<div class="card-body">
<p class="small text-body-secondary">
A versioned JSON export of zones, records, networks, policies, list definitions and
settings. Password hashes and API token secrets are deliberately excluded.
</p>
<div class="d-grid gap-2 mb-3">
<a href="/settings/config/export" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-download me-1"></i>Export configuration
</a>
<a href="/settings/config/export?include_domains=true" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-download me-1"></i>Export including list domains
</a>
</div>
<form method="post" action="/settings/config/import" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<div class="mb-2">
<label class="form-label small" for="configFile">Import a configuration export</label>
<input type="file" class="form-control form-control-sm" id="configFile" name="file"
accept=".json,application/json" required>
</div>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" role="switch" id="applySettings"
name="apply_settings" value="true">
<label class="form-check-label small" for="applySettings">Also apply the settings</label>
</div>
<div class="form-text mb-2">
Objects that already exist are kept as they are and reported, never overwritten.
</div>
<button type="submit" class="btn btn-sm btn-outline-primary w-100">
<i class="bi bi-upload me-1"></i>Import
</button>
</form>
</div>
</div>
</div>
<div class="col-12 col-xl-8">
<form method="post" action="/settings/database">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<div class="card mb-3">
<div class="card-header">Automatic backups</div>
<div class="card-body">
<div class="form-check form-switch mb-3">
<input type="hidden" name="backup_enabled" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="backupEnabled"
name="backup_enabled" value="true" {{if $s.Backup.Enabled}}checked{{end}}>
<label class="form-check-label" for="backupEnabled">Back up the database on a schedule</label>
</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label" for="backupDir">Backup directory</label>
<input type="text" class="form-control mono" id="backupDir" name="backup_dir"
value="{{$s.Backup.Directory}}" placeholder="/var/lib/vibedns/backups">
</div>
<div class="col-6 col-md-3">
<label class="form-label" for="intervalHours">Interval</label>
<div class="input-group">
<input type="number" class="form-control" id="intervalHours" name="interval_hours"
value="{{$s.Backup.IntervalHours}}" min="1" max="8760">
<span class="input-group-text">h</span>
</div>
</div>
<div class="col-6 col-md-3">
<label class="form-label" for="retention">Keep</label>
<div class="input-group">
<input type="number" class="form-control" id="retention" name="retention"
value="{{$s.Backup.Retention}}" min="1" max="1000">
<span class="input-group-text">files</span>
</div>
</div>
</div>
<div class="alert alert-info small mt-3 mb-0">
<i class="bi bi-info-circle me-1"></i>
Backups use SQLite's <span class="mono">VACUUM INTO</span>, which writes a
transactionally consistent copy while the database is in use. Copying the file
with <span class="mono">cp</span> instead would capture a torn snapshot whose
committed data still lives in the write-ahead log.
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save backup settings
</button>
</div>
</div>
</div>
</form>
<div class="card">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<span>
Backups
{{if $bs.Count}}
<span class="text-body-secondary small">
— {{$bs.Count}} file{{if ne $bs.Count 1}}s{{end}}, {{bytes $bs.TotalBytes}}
</span>
{{end}}
</span>
{{template "postform" dict
"Action" "/settings/database/backup" "CSRF" $csrf
"Label" "Back up now" "Icon" "bi-play-fill"
"Class" "btn btn-sm btn-primary"}}
</div>
{{if $bs.LastError}}
<div class="card-body pb-0">
<div class="alert alert-danger small mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>
Last backup failed: {{$bs.LastError}}
</div>
</div>
{{end}}
{{if .Data.Backups}}
<div class="table-responsive">
<table class="table table-hover align-middle table-compact">
<thead>
<tr><th>File</th><th class="text-end">Size</th><th>Created</th><th class="row-actions">Actions</th></tr>
</thead>
<tbody>
{{range .Data.Backups}}
<tr>
<td class="mono small">{{.Name}}</td>
<td class="text-end">{{bytes .SizeBytes}}</td>
<td class="small text-body-secondary" title="{{datetime .CreatedAt}}">{{timeAgo .CreatedAt}}</td>
<td class="row-actions">
<div class="btn-group">
<a href="/settings/database/backup/{{.Name}}" class="btn btn-sm btn-outline-secondary"
title="Download this backup">
<i class="bi bi-download"></i>
</a>
<button type="button" class="btn btn-sm btn-outline-warning"
data-bs-toggle="modal" data-bs-target="#restoreModal"
data-backup-name="{{.Name}}" title="Restore from this backup">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
{{template "confirmform" dict
"Action" (printf "/settings/database/backup/%s/delete" .Name) "CSRF" $csrf
"Icon" "bi-trash" "Title" "Delete this backup"
"Message" (printf "Delete the backup file %s?" .Name)}}
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="card-body">
{{template "empty" dict "Icon" "bi-archive" "Title" "No backups yet"
"Message" "Run one now, or enable the schedule above. Backups are written to the directory configured here and are restricted to the owning user."}}
</div>
{{end}}
</div>
</div>
</div>
{{/* ---- Restore modal ---- */}}
<div class="modal fade" id="restoreModal" tabindex="-1" aria-hidden="true" aria-labelledby="restoreModalLabel">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" id="restoreForm" action="/settings/database/backup/x/restore">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<div class="modal-header bg-warning-subtle">
<h5 class="modal-title" id="restoreModalLabel">
<i class="bi bi-exclamation-triangle me-1"></i>Restore from backup
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
This replaces <strong>every</strong> zone, record, policy, list and setting with
the contents of <span class="mono" id="restoreName"></span>.
</p>
<p class="small text-body-secondary">
The swap happens on the next start, not immediately: overwriting the database
underneath a running process would leave open connections reading a file that no
longer exists. Your current database is preserved next to it, so this is
reversible.
</p>
<div class="mb-2">
<label class="form-label" for="restoreConfirm">
Type the file name to confirm
</label>
<input type="text" class="form-control mono" id="restoreConfirm" name="confirm"
required autocomplete="off" placeholder="vibedns-00000000-000000.db">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-warning">Stage restore</button>
</div>
</form>
</div>
</div>
</div>
{{end}}
+157
View File
@@ -0,0 +1,157 @@
{{define "content"}}
{{$s := .Data.S}}
<h1 class="page-title mb-1">Settings</h1>
<p class="text-body-secondary mb-4">Server configuration, stored in the database.</p>
{{template "settingsnav" .}}
<form method="post" action="/settings/dns">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="row g-3">
<div class="col-12 col-xl-8">
<div class="card">
<div class="card-header">DNS service</div>
<div class="card-body">
<div class="form-section">
<div class="form-section-title">Listeners</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label" for="udpListen">
UDP listen address {{template "restartbadge"}}
</label>
<input type="text" class="form-control mono" id="udpListen" name="udp_listen"
value="{{$s.DNS.UDPListen}}" placeholder="0.0.0.0:53" required>
<div class="form-text">
Currently bound to <span class="mono">{{.Data.BoundUDP}}</span>.
Use <span class="mono">[::]:53</span> to accept IPv4 and IPv6 together.
</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="tcpListen">
TCP listen address {{template "restartbadge"}}
</label>
<input type="text" class="form-control mono" id="tcpListen" name="tcp_listen"
value="{{$s.DNS.TCPListen}}" placeholder="0.0.0.0:53" required>
<div class="form-text">
Currently bound to <span class="mono">{{.Data.BoundTCP}}</span>.
TCP is required: it is how large answers and zone transfers are carried.
</div>
</div>
</div>
</div>
<div class="form-section">
<div class="form-section-title">Recursion</div>
<div class="form-check form-switch mb-2">
<input type="hidden" name="recursion" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="recursion"
name="recursion" value="true" {{if $s.DNS.Recursion}}checked{{end}}>
<label class="form-check-label" for="recursion">
Answer recursive queries for permitted clients
</label>
</div>
<div class="form-text">
With this off, the server answers only from its own authoritative zones and
refuses everything else. Which clients may recurse is controlled on the
<a href="/settings/resolver">Resolver</a> page — that list is what prevents an
open resolver.
</div>
</div>
<div class="form-section">
<div class="form-section-title">EDNS and message sizes</div>
<div class="form-check form-switch mb-3">
<input type="hidden" name="edns_enabled" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="ednsEnabled"
name="edns_enabled" value="true" {{if $s.DNS.EDNSEnabled}}checked{{end}}>
<label class="form-check-label" for="ednsEnabled">Enable EDNS(0)</label>
</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label" for="ednsSize">Advertised UDP buffer size</label>
<input type="number" class="form-control" id="ednsSize" name="edns_udp_size"
value="{{$s.DNS.EDNSUDPSize}}" min="512" max="65535">
<div class="form-text">
1232 bytes is the widely recommended value: it stays under the smallest
path MTU in common use, so answers are not silently lost to fragmentation.
</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="maxUDP">Maximum UDP response size</label>
<input type="number" class="form-control" id="maxUDP" name="max_udp_response"
value="{{$s.DNS.MaxUDPResponse}}" min="512" max="65535">
<div class="form-text">
Larger answers are truncated, which tells the client to retry over TCP.
</div>
</div>
</div>
</div>
<div class="form-section">
<div class="form-section-title">Defaults and behaviour</div>
<div class="row g-3">
<div class="col-12 col-md-4">
<label class="form-label" for="defTTL">Default TTL for new zones</label>
<input type="number" class="form-control" id="defTTL" name="default_ttl"
value="{{$s.DNS.DefaultTTL}}" min="1" max="604800">
<div class="form-text">Seconds</div>
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="tcpIdle">TCP idle timeout</label>
<input type="number" class="form-control" id="tcpIdle" name="tcp_idle"
value="{{$s.DNS.TCPIdleSeconds}}" min="1" max="120">
<div class="form-text">Seconds a TCP connection is kept open between queries</div>
</div>
<div class="col-12 col-md-4 d-flex align-items-center">
<div class="form-check form-switch mt-3">
<input type="hidden" name="expose_version" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="exposeVersion"
name="expose_version" value="true" {{if $s.DNS.ExposeVersion}}checked{{end}}>
<label class="form-check-label" for="exposeVersion">
Answer version.bind
</label>
<div class="form-text">
Off by default: publishing the software version only helps someone
looking for a matching exploit.
</div>
</div>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save DNS settings
</button>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card">
<div class="card-header">Service status</div>
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-3">
<span class="status-dot {{if .Data.Running}}status-up{{else}}status-down{{end}}"></span>
<span class="fw-semibold">
{{if .Data.Running}}Listeners running{{else}}Listeners stopped{{end}}
</span>
</div>
<dl class="row small mb-0">
<dt class="col-5 text-body-secondary">UDP</dt>
<dd class="col-7 mono">{{.Data.BoundUDP}}</dd>
<dt class="col-5 text-body-secondary">TCP</dt>
<dd class="col-7 mono">{{.Data.BoundTCP}}</dd>
<dt class="col-5 text-body-secondary">Recursion</dt>
<dd class="col-7">{{if $s.DNS.Recursion}}enabled{{else}}disabled{{end}}</dd>
</dl>
</div>
</div>
</div>
</div>
</form>
{{end}}
+203
View File
@@ -0,0 +1,203 @@
{{define "content"}}
{{$s := .Data.S}}
<h1 class="page-title mb-1">Settings</h1>
<p class="text-body-secondary mb-4">Server configuration, stored in the database.</p>
{{template "settingsnav" .}}
<form method="post" action="/settings/http">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="row g-3">
<div class="col-12 col-xl-8">
<div class="card mb-3">
<div class="card-header">Management interface</div>
<div class="card-body">
<div class="form-section">
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label" for="httpListen">
Listen address {{template "restartbadge"}}
</label>
<input type="text" class="form-control mono" id="httpListen" name="listen"
value="{{$s.HTTP.Listen}}" placeholder="127.0.0.1:8080" required>
<div class="form-text">
Currently bound to <span class="mono">{{.Data.Bound}}</span>.
Binding to loopback and reaching it over SSH or a VPN keeps the
interface off the network entirely.
</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="baseURL">Base URL</label>
<input type="url" class="form-control" id="baseURL" name="base_url"
value="{{$s.HTTP.BaseURL}}" placeholder="https://dns.example.com">
<div class="form-text">
Only needed behind a reverse proxy, for building absolute links.
</div>
</div>
</div>
</div>
<div class="form-section">
<div class="form-section-title">Reverse proxy</div>
<label class="form-label" for="trustedProxies">Trusted proxies</label>
<textarea class="form-control list-input" id="trustedProxies" name="trusted_proxies" rows="3"
placeholder="10.0.0.5
172.18.0.0/16">{{joinLines $s.HTTP.TrustedProxies}}</textarea>
<div class="form-text">
<code>X-Forwarded-For</code> is honoured only when the request arrives from one of
these addresses. Leave empty when there is no proxy: trusting the header
unconditionally would let any client forge its own address and slip past the
sign-in rate limiter.
</div>
</div>
<div class="form-section">
<div class="form-section-title">Limits</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label" for="maxUpload">Maximum upload size</label>
<div class="input-group">
<input type="number" class="form-control" id="maxUpload" name="max_upload_mb"
value="{{$s.HTTP.MaxUploadMB}}" min="1" max="4096">
<span class="input-group-text">MB</span>
</div>
<div class="form-text">
Applies to blocklist and zone file imports. Large public blocklists are
commonly 550 MB.
</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="httpRateLimit">Request rate limit</label>
<div class="input-group">
<input type="number" class="form-control" id="httpRateLimit" name="http_rate_limit"
value="{{$s.HTTP.RateLimitPerMin}}" min="10" max="1000000">
<span class="input-group-text">/min</span>
</div>
<div class="form-text">Per client address, excluding static assets.</div>
</div>
</div>
</div>
<div class="form-section">
<div class="form-section-title">Metrics</div>
<div class="form-check form-switch mb-2">
<input type="hidden" name="metrics_enabled" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="metricsEnabled"
name="metrics_enabled" value="true" {{if $s.HTTP.MetricsEnabled}}checked{{end}}>
<label class="form-check-label" for="metricsEnabled">
Expose Prometheus metrics at <span class="mono">/metrics</span>
</label>
</div>
<div class="form-check form-switch">
<input type="hidden" name="metrics_public" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="metricsPublic"
name="metrics_public" value="true" {{if $s.HTTP.MetricsPublic}}checked{{end}}>
<label class="form-check-label" for="metricsPublic">
Allow metrics without authentication
</label>
</div>
<div class="form-text">
Metrics reveal query volumes and cache behaviour. Leave authentication on
unless the endpoint is reachable only by your scraper.
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">DNS rate limiting</div>
<div class="card-body">
<div class="form-check form-switch mb-3">
<input type="hidden" name="dns_ratelimit_enabled" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="rlEnabled"
name="dns_ratelimit_enabled" value="true" {{if $s.RateLimit.Enabled}}checked{{end}}>
<label class="form-check-label" for="rlEnabled">
Limit how fast a single client may query
</label>
</div>
<div class="form-text mb-3">
Clients over the limit are dropped without a reply. Answering would let an
attacker use this server to amplify traffic at a spoofed victim, which is the
abuse the limiter exists to prevent.
</div>
<div class="row g-3">
<div class="col-6 col-md-3">
<label class="form-label" for="dnsQPS">Queries per second</label>
<input type="number" class="form-control" id="dnsQPS" name="dns_qps"
value="{{$s.RateLimit.QPS}}" min="1" max="1000000">
</div>
<div class="col-6 col-md-3">
<label class="form-label" for="dnsBurst">Burst allowance</label>
<input type="number" class="form-control" id="dnsBurst" name="dns_burst"
value="{{$s.RateLimit.Burst}}" min="1" max="1000000">
<div class="form-text">Never below the rate</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="exemptNetworks">Exempt networks</label>
<textarea class="form-control list-input" id="exemptNetworks" name="exempt_networks" rows="3"
placeholder="127.0.0.0/8
10.0.0.10">{{joinLines $s.RateLimit.ExemptNetworks}}</textarea>
<div class="form-text">
Trusted infrastructure — a downstream forwarder or a busy mail server —
is never limited.
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save web server settings
</button>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card mb-3">
<div class="card-header">Rate limiter activity</div>
<div class="card-body">
<dl class="row small mb-0">
<dt class="col-7 text-body-secondary">State</dt>
<dd class="col-5 text-end">
{{if .Data.RateLimit.Enabled}}<span class="badge text-bg-success">on</span>
{{else}}<span class="badge text-bg-secondary">off</span>{{end}}
</dd>
<dt class="col-7 text-body-secondary">Allowed</dt>
<dd class="col-5 text-end">{{num .Data.RateLimit.Allowed}}</dd>
<dt class="col-7 text-body-secondary">Denied</dt>
<dd class="col-5 text-end {{if gt .Data.RateLimit.Denied 0}}text-warning fw-semibold{{end}}">
{{num .Data.RateLimit.Denied}}
</dd>
<dt class="col-7 text-body-secondary">Tracked clients</dt>
<dd class="col-5 text-end">{{num .Data.RateLimit.TrackedClients}}</dd>
</dl>
</div>
</div>
<div class="card">
<div class="card-header">Observability endpoints</div>
<div class="card-body small">
<dl class="row mb-0">
<dt class="col-4 mono">/healthz</dt>
<dd class="col-8 text-body-secondary">Process liveness. Never touches the database.</dd>
<dt class="col-4 mono">/readyz</dt>
<dd class="col-8 text-body-secondary">Readiness: listeners up and database reachable.</dd>
<dt class="col-4 mono">/metrics</dt>
<dd class="col-8 text-body-secondary">
Prometheus exposition.
{{if not $s.HTTP.MetricsEnabled}}<span class="badge text-bg-secondary">disabled</span>
{{else if $s.HTTP.MetricsPublic}}<span class="badge text-bg-warning">public</span>
{{else}}<span class="badge text-bg-success">authenticated</span>{{end}}
</dd>
</dl>
</div>
</div>
</div>
</div>
</form>
{{end}}
+149
View File
@@ -0,0 +1,149 @@
{{define "content"}}
{{$s := .Data.S}}
<h1 class="page-title mb-1">Settings</h1>
<p class="text-body-secondary mb-4">Server configuration, stored in the database.</p>
{{template "settingsnav" .}}
<form method="post" action="/settings/logging">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="row g-3">
<div class="col-12 col-xl-8">
<div class="card">
<div class="card-header">Query logging</div>
<div class="card-body">
<div class="form-section">
<div class="form-check form-switch mb-2">
<input type="hidden" name="querylog_enabled" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="qlEnabled"
name="querylog_enabled" value="true" {{if $s.QueryLog.Enabled}}checked{{end}}>
<label class="form-check-label" for="qlEnabled">Record DNS queries in the database</label>
</div>
<div class="form-text">
Records are buffered in memory and written in batches, so logging never delays
a DNS response. Under sustained overload entries are dropped rather than queued.
</div>
</div>
<div class="form-section">
<div class="form-section-title">Retention</div>
<div class="alert alert-info small">
<i class="bi bi-info-circle me-1"></i>
Both limits are applied on every cleanup pass: rows older than the retention
period go first, then the table is trimmed to the row cap. Set a value to 0 to
disable that limit — but leaving both at 0 lets the database grow without bound.
</div>
<div class="row g-3">
<div class="col-6 col-md-4">
<label class="form-label" for="retentionDays">Retention</label>
<div class="input-group">
<input type="number" class="form-control" id="retentionDays" name="retention_days"
value="{{$s.QueryLog.RetentionDays}}" min="0" max="3650">
<span class="input-group-text">days</span>
</div>
</div>
<div class="col-6 col-md-4">
<label class="form-label" for="maxRows">Maximum rows</label>
<input type="number" class="form-control" id="maxRows" name="max_rows"
value="{{$s.QueryLog.MaxRows}}" min="0" max="100000000">
<div class="form-text">Currently {{num .Data.QueryLogRows}} stored</div>
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="cleanupMinutes">Cleanup interval</label>
<div class="input-group">
<input type="number" class="form-control" id="cleanupMinutes" name="cleanup_minutes"
value="{{$s.QueryLog.CleanupMinutes}}" min="1" max="1440">
<span class="input-group-text">min</span>
</div>
</div>
</div>
</div>
<div class="form-section">
<div class="form-section-title">Exclusions</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label" for="ignoreNetworks">Ignore these networks</label>
<textarea class="form-control list-input" id="ignoreNetworks" name="ignore_networks" rows="4"
placeholder="127.0.0.0/8">{{joinLines $s.QueryLog.IgnoreNetworks}}</textarea>
<div class="form-text">Queries from these clients are not logged.</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="ignoreDomains">Ignore these domains</label>
<textarea class="form-control list-input" id="ignoreDomains" name="ignore_domains" rows="4"
placeholder="internal.example.com">{{joinLines $s.QueryLog.IgnoreDomains}}</textarea>
<div class="form-text">Subdomains are excluded too. Useful for chatty monitoring checks.</div>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save logging settings
</button>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card mb-3">
<div class="card-header">Application log</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label" for="logLevel">Log level</label>
<select class="form-select" id="logLevel" name="log_level">
{{range list "debug" "info" "warn" "error"}}
<option value="{{.}}" {{if eq . $s.Logging.Level}}selected{{end}}>{{title .}}</option>
{{end}}
</select>
<div class="form-text">
Debug logs every request and resolution failure; useful when diagnosing, noisy
otherwise.
</div>
</div>
<div class="mb-3">
<label class="form-label" for="logFormat">Format</label>
<select class="form-select" id="logFormat" name="log_format">
<option value="text" {{if eq $s.Logging.Format "text"}}selected{{end}}>Text — readable in a terminal</option>
<option value="json" {{if eq $s.Logging.Format "json"}}selected{{end}}>JSON — for log shippers</option>
</select>
</div>
<div>
<label class="form-label" for="auditMaxRows">Audit log maximum rows</label>
<input type="number" class="form-control" id="auditMaxRows" name="audit_max_rows"
value="{{$s.Logging.AuditMaxRows}}" min="100" max="10000000">
<div class="form-text">Oldest entries are trimmed past this count.</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">Query log writer</div>
<div class="card-body">
<dl class="row small mb-0">
<dt class="col-7 text-body-secondary">State</dt>
<dd class="col-5 text-end">
{{if .Data.Stats.Enabled}}<span class="badge text-bg-success">on</span>
{{else}}<span class="badge text-bg-secondary">off</span>{{end}}
</dd>
<dt class="col-7 text-body-secondary">Rows written</dt>
<dd class="col-5 text-end">{{num .Data.Stats.Written}}</dd>
<dt class="col-7 text-body-secondary">Buffered</dt>
<dd class="col-5 text-end">{{num .Data.Stats.Buffered}}</dd>
<dt class="col-7 text-body-secondary">Dropped</dt>
<dd class="col-5 text-end {{if gt .Data.Stats.Dropped 0}}text-warning fw-semibold{{end}}">
{{num .Data.Stats.Dropped}}
</dd>
<dt class="col-7 text-body-secondary">Pruned</dt>
<dd class="col-5 text-end">{{num .Data.Stats.Pruned}}</dd>
</dl>
</div>
</div>
</div>
</div>
</form>
{{end}}
+160
View File
@@ -0,0 +1,160 @@
{{define "content"}}
{{$s := .Data.S}}
<h1 class="page-title mb-1">Settings</h1>
<p class="text-body-secondary mb-4">Server configuration, stored in the database.</p>
{{template "settingsnav" .}}
<form method="post" action="/settings/resolver">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="row g-3">
<div class="col-12 col-xl-8">
<div class="card">
<div class="card-header">Recursive resolution</div>
<div class="card-body">
<div class="form-section">
<div class="form-section-title">Upstream servers</div>
<label class="form-label" for="upstreams">Resolvers, one per line</label>
<textarea class="form-control list-input" id="upstreams" name="upstreams" rows="5"
placeholder="1.1.1.1:53
9.9.9.9:53
[2606:4700:4700::1111]:53">{{joinLines $s.Resolver.Upstreams}}</textarea>
<div class="form-text">
Literal IP addresses only — a host name here could not be resolved, because
this is the resolver that would have to resolve it. Port 53 is assumed if
omitted; bracket IPv6 addresses when specifying a port.
</div>
</div>
<div class="form-section">
<div class="form-section-title">Selection and timing</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label" for="strategy">Server selection</label>
<select class="form-select" id="strategy" name="strategy">
{{range .Data.Strategies}}
<option value="{{.}}" {{if eq . $s.Resolver.Strategy}}selected{{end}}>
{{if eq . "fastest"}}Fastest — prefer the lowest observed latency
{{else if eq . "sequential"}}Sequential — always try the first server first
{{else if eq . "round_robin"}}Round robin — spread queries evenly
{{else}}Random — pick one at random{{end}}
</option>
{{end}}
</select>
<div class="form-text">
A server that fails repeatedly is rested briefly and moved to the back of
the order, whichever strategy is chosen.
</div>
</div>
<div class="col-6 col-md-3">
<label class="form-label" for="timeoutMS">Timeout</label>
<input type="number" class="form-control" id="timeoutMS" name="timeout_ms"
value="{{$s.Resolver.TimeoutMS}}" min="100" max="30000">
<div class="form-text">Milliseconds per attempt</div>
</div>
<div class="col-6 col-md-3">
<label class="form-label" for="retries">Retries</label>
<input type="number" class="form-control" id="retries" name="retries"
value="{{$s.Resolver.Retries}}" min="0" max="10">
<div class="form-text">Extra servers to try</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="maxConcurrent">Maximum concurrent queries</label>
<input type="number" class="form-control" id="maxConcurrent" name="max_concurrent"
value="{{$s.Resolver.MaxConcurrent}}" min="1" max="10000">
<div class="form-text">
Bounds how many upstream exchanges are in flight at once.
</div>
</div>
</div>
</div>
<div class="form-section">
<div class="form-section-title">Protocol preferences</div>
<div class="form-check form-switch mb-2">
<input type="hidden" name="dnssec" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="dnssec"
name="dnssec" value="true" {{if $s.Resolver.DNSSEC}}checked{{end}}>
<label class="form-check-label" for="dnssec">Request DNSSEC records from upstreams</label>
</div>
<div class="form-text mb-3">
Sets the DO bit so signatures are returned and passed through to clients that
ask for them. Validation itself is performed by the upstream resolver.
</div>
<div class="form-check form-switch">
<input type="hidden" name="prefer_ipv6" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="preferIPv6"
name="prefer_ipv6" value="true" {{if $s.Resolver.PreferIPv6}}checked{{end}}>
<label class="form-check-label" for="preferIPv6">Prefer IPv6 upstreams</label>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save resolver settings
</button>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card mb-3 border-warning-subtle">
<div class="card-header bg-warning-subtle">
<i class="bi bi-shield-lock me-1"></i>Recursion access control
</div>
<div class="card-body">
<div class="alert alert-warning small">
An empty allow list denies recursion to everyone. A list that is too broad turns
this server into an open resolver, which will be abused for amplification
attacks. Keep it to networks you control.
</div>
<div class="mb-3">
<label class="form-label" for="allowNetworks">Allowed networks</label>
<textarea class="form-control list-input" id="allowNetworks" name="allow_networks" rows="7"
placeholder="192.168.0.0/16
10.0.0.0/8">{{joinLines $s.Resolver.AllowNetworks}}</textarea>
<div class="form-text">One CIDR block or address per line.</div>
</div>
<div>
<label class="form-label" for="denyNetworks">Denied networks</label>
<textarea class="form-control list-input" id="denyNetworks" name="deny_networks" rows="4"
placeholder="192.168.99.0/24">{{joinLines $s.Resolver.DenyNetworks}}</textarea>
<div class="form-text">
Evaluated before the allow list, so you can exclude a range from a broader
allowance.
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">Current upstream health</div>
<div class="card-body p-0">
<table class="table table-sm table-compact mb-0">
<tbody>
{{range .Data.Upstreams}}
<tr>
<td class="mono small">{{.Address}}</td>
<td>
{{if .Healthy}}<span class="badge text-bg-success">ok</span>
{{else}}<span class="badge text-bg-danger">down</span>{{end}}
</td>
<td class="text-end small">{{ms .LatencyMS}}</td>
</tr>
{{else}}
<tr><td class="text-body-secondary small p-3">No upstreams configured.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</div>
</form>
{{end}}
+130
View File
@@ -0,0 +1,130 @@
{{define "content"}}
{{$form := .Data.Form}}{{$r := .Data.Result}}
<div class="mb-4">
<h1 class="page-title">Tools</h1>
<p class="text-body-secondary mb-0">
Run a query through the full server pipeline exactly as a client on a given address
would experience it — policy, authoritative zones, cache and recursion included.
</p>
</div>
<div class="row g-3">
<div class="col-12 col-lg-4">
<div class="card">
<div class="card-header">Look up a name</div>
<div class="card-body">
<form method="post" action="/tools/lookup">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="mb-3">
<label class="form-label" for="lookupName">Name</label>
<input type="text" class="form-control mono" id="lookupName" name="name"
value="{{index $form "name"}}" placeholder="example.com" required autocomplete="off">
</div>
<div class="mb-3">
<label class="form-label" for="lookupType">Record type</label>
<select class="form-select" id="lookupType" name="type">
{{$sel := index $form "type"}}
{{range list "A" "AAAA" "CNAME" "MX" "TXT" "NS" "SRV" "PTR" "CAA" "SOA" "DS" "DNSKEY" "HTTPS" "ANY"}}
<option value="{{.}}" {{if eq . $sel}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
<div class="mb-3">
<label class="form-label" for="lookupClient">Query as client</label>
<input type="text" class="form-control mono" id="lookupClient" name="client"
value="{{index $form "client"}}" placeholder="127.0.0.1" autocomplete="off">
<div class="form-text">
Policy and recursion ACLs are evaluated against this address, so you can
confirm what a guest device actually sees.
</div>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch" id="lookupDNSSEC"
name="dnssec" value="true" {{if index $form "dnssec"}}checked{{end}}>
<label class="form-check-label" for="lookupDNSSEC">Request DNSSEC records</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="bi bi-search me-1"></i>Resolve
</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-12 col-lg-8">
{{if .Data.Error}}
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle me-1"></i>{{.Data.Error}}
</div>
{{end}}
{{if $r}}
<div class="card mb-3">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<span class="mono">{{$r.Question}}</span>
<span>
<span class="badge {{rcodeBadge $r.Rcode}}">{{$r.Rcode}}</span>
<span class="badge {{sourceBadge $r.Source}}">{{$r.Source}}</span>
<span class="badge text-bg-light text-dark">{{duration $r.Duration}}</span>
</span>
</div>
<div class="card-body">
<h2 class="h6">Answer section</h2>
{{if $r.Answers}}
<pre class="answer-block mb-3">{{range $r.Answers}}{{.}}
{{end}}</pre>
{{else}}
<p class="text-body-secondary fst-italic">Empty — no records of that type exist for this name.</p>
{{end}}
{{if $r.Authority}}
<h2 class="h6">Authority section</h2>
<pre class="answer-block mb-0">{{range $r.Authority}}{{.}}
{{end}}</pre>
{{end}}
</div>
</div>
{{end}}
{{if .Data.ListHits}}
<div class="card mb-3">
<div class="card-header">Policy list matches</div>
<div class="card-body">
<p class="text-body-secondary small">
Lists that cover this name. Whether it is actually blocked also depends on which
policies the client's network has assigned.
</p>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>List</th><th>Kind</th><th>Matched entry</th></tr></thead>
<tbody>
{{range .Data.ListHits}}
<tr>
<td><a href="/policies/lists/{{.ListID}}" class="text-decoration-none">{{.ListName}}</a></td>
<td>
<span class="badge {{if eq .Kind "blacklist"}}text-bg-danger{{else}}text-bg-success{{end}}">{{.Kind}}</span>
</td>
<td class="mono">{{.Matched}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
{{end}}
{{if not $r}}
<div class="card">
<div class="card-body">
{{template "empty" dict "Icon" "bi-tools" "Title" "No lookup run yet"
"Message" "Enter a name on the left and resolve it. The result shows which stage of the pipeline answered — a local zone, the cache, an upstream resolver, or a policy block."}}
</div>
</div>
{{end}}
</div>
</div>
{{end}}
+193
View File
@@ -0,0 +1,193 @@
{{define "content"}}
{{$z := .Data.Zone}}{{$new := .Data.IsNew}}{{$kind := .Data.Kind}}
{{$isReverse := hasPrefix $kind "reverse"}}
<nav aria-label="breadcrumb">
<ol class="breadcrumb small">
<li class="breadcrumb-item">
<a href="{{if $isReverse}}/zones/reverse{{else}}/zones{{end}}">
{{if $isReverse}}Reverse Zones{{else}}Forward Zones{{end}}
</a>
</li>
<li class="breadcrumb-item active" aria-current="page">
{{if $new}}New zone{{else}}{{trimDot $z.Name}}{{end}}
</li>
</ol>
</nav>
<h1 class="page-title mb-4">{{if $new}}Create a zone{{else}}Edit {{trimDot $z.Name}}{{end}}</h1>
<form method="post" action="{{.Data.FormAction}}">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="row g-3">
<div class="col-12 col-xl-8">
<div class="card mb-3">
<div class="card-header">Zone identity</div>
<div class="card-body">
{{if $new}}
<div class="mb-3">
<label class="form-label" for="kindSelect">Zone type</label>
<select class="form-select" id="kindSelect" name="kind">
<option value="forward" {{if eq $kind "forward"}}selected{{end}}>Forward — resolve names to addresses</option>
<option value="reverse4" {{if eq $kind "reverse4"}}selected{{end}}>Reverse IPv4 — resolve addresses back to names</option>
<option value="reverse6" {{if eq $kind "reverse6"}}selected{{end}}>Reverse IPv6 — resolve addresses back to names</option>
</select>
</div>
{{if $isReverse}}
<div class="mb-3">
<label class="form-label" for="reverseCidr">Subnet</label>
<input type="text" class="form-control mono" id="reverseCidr" name="cidr"
placeholder="192.168.1.0/24" autocomplete="off">
<div class="form-text" id="reversePreview"></div>
<div class="form-text">
Enter the subnet and the matching <code>in-addr.arpa</code> or <code>ip6.arpa</code>
zone name is worked out for you. Leave the zone name below blank to use it.
</div>
</div>
{{end}}
{{else}}
<input type="hidden" name="kind" value="{{$kind}}">
{{end}}
<div class="mb-3">
<label class="form-label" for="zoneName">
Zone name {{if and $new $isReverse}}<span class="text-body-secondary fw-normal">(optional if a subnet is given)</span>{{end}}
</label>
<input type="text" class="form-control mono" id="zoneName" name="name"
value="{{trimDot $z.Name}}" placeholder="example.com"
{{if not $isReverse}}required{{end}} autocomplete="off">
<div class="form-text">The apex of the zone. A trailing dot is added automatically.</div>
</div>
<div class="mb-3">
<label class="form-label" for="zoneDescription">Description</label>
<input type="text" class="form-control" id="zoneDescription" name="description"
value="{{$z.Description}}" placeholder="What this zone is for">
</div>
<div class="form-check form-switch">
<input type="hidden" name="enabled" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="zoneEnabled"
name="enabled" value="true" {{if or $new $z.Enabled}}checked{{end}}>
<label class="form-check-label" for="zoneEnabled">
Enabled — the server answers authoritatively for this zone
</label>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">Start of authority</div>
<div class="card-body">
<p class="text-body-secondary small">
The SOA record is maintained for you. These values are what it will contain.
</p>
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label" for="primaryNS">Primary name server</label>
<input type="text" class="form-control mono" id="primaryNS" name="primary_ns"
value="{{trimDot $z.PrimaryNS}}" placeholder="ns1.example.com">
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="adminEmail">Responsible email</label>
<input type="text" class="form-control" id="adminEmail" name="admin_email"
value="{{$z.AdminEmail}}" placeholder="hostmaster@example.com">
<div class="form-text">Stored in the SOA in its DNS form.</div>
</div>
<div class="col-6 col-md-3">
<label class="form-label" for="defaultTTL">Default TTL</label>
<input type="number" class="form-control" id="defaultTTL" name="default_ttl"
value="{{default 3600 $z.DefaultTTL}}" min="1" max="604800" required>
<div class="form-text">Seconds</div>
</div>
<div class="col-6 col-md-3">
<label class="form-label" for="refresh">Refresh</label>
<input type="number" class="form-control" id="refresh" name="refresh"
value="{{default 7200 $z.Refresh}}" min="1">
</div>
<div class="col-6 col-md-3">
<label class="form-label" for="retry">Retry</label>
<input type="number" class="form-control" id="retry" name="retry"
value="{{default 3600 $z.Retry}}" min="1">
</div>
<div class="col-6 col-md-3">
<label class="form-label" for="expire">Expire</label>
<input type="number" class="form-control" id="expire" name="expire"
value="{{default 1209600 $z.Expire}}" min="1">
</div>
<div class="col-6 col-md-3">
<label class="form-label" for="minimum">Negative TTL</label>
<input type="number" class="form-control" id="minimum" name="minimum"
value="{{default 3600 $z.Minimum}}" min="1">
<div class="form-text">SOA minimum</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card mb-3">
<div class="card-header">Serial number</div>
<div class="card-body">
<div class="mb-3">
<div class="text-body-secondary small">Current serial</div>
<div class="fs-4 fw-semibold mono">{{default 1 $z.Serial}}</div>
</div>
<div class="form-check form-switch mb-3">
<input type="hidden" name="auto_serial" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="autoSerial"
name="auto_serial" value="true" {{if or $new $z.AutoSerial}}checked{{end}}>
<label class="form-check-label" for="autoSerial">Increment automatically</label>
<div class="form-text">
The serial advances on every record change, which is what secondary servers
watch to know a zone was updated.
</div>
</div>
<div class="form-check mb-2">
<input type="hidden" name="override_serial" value="false">
<input class="form-check-input" type="checkbox" id="overrideSerial"
name="override_serial" value="true">
<label class="form-check-label" for="overrideSerial">Set the serial manually</label>
</div>
<input type="number" class="form-control mono" name="serial"
value="{{default 1 $z.Serial}}" min="1" max="4294967295"
aria-label="Manual serial number">
<div class="form-text">
Only needed when migrating a zone from another server that is already at a
higher serial. Tick the box above for this value to be used.
</div>
</div>
</div>
{{if not $new}}
<div class="card mb-3">
<div class="card-header">Zone file</div>
<div class="card-body d-grid gap-2">
<a href="/zones/{{$z.ID}}/export" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-download me-1"></i>Export as BIND zone file
</a>
<a href="/zones/{{$z.ID}}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-list-ul me-1"></i>Manage records
</a>
</div>
</div>
{{end}}
<div class="card">
<div class="card-body d-grid gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>{{if $new}}Create zone{{else}}Save changes{{end}}
</button>
<a href="{{if $isReverse}}/zones/reverse{{else}}/zones{{end}}" class="btn btn-outline-secondary">Cancel</a>
</div>
</div>
</div>
</div>
</form>
{{end}}
+111
View File
@@ -0,0 +1,111 @@
{{define "content"}}
{{$csrf := .CSRF}}{{$reverse := .Data.Reverse}}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 class="page-title">{{.Title}}</h1>
<p class="text-body-secondary mb-0">
{{if $reverse}}PTR zones for IPv4 and IPv6 address space.
{{else}}Zones this server answers authoritatively.{{end}}
</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a href="/zones/new?kind={{if $reverse}}reverse4{{else}}forward{{end}}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>Add Zone
</a>
</div>
</div>
<div class="card">
<div class="card-body pb-2">
<form method="get" data-autosubmit class="row g-2 align-items-center mb-3">
<div class="col-12 col-md-6 col-lg-4">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" name="q" value="{{.Data.Search}}"
placeholder="Search zones..." aria-label="Search zones">
</div>
</div>
<div class="col-auto">
<noscript><button class="btn btn-sm btn-outline-secondary" type="submit">Search</button></noscript>
</div>
</form>
{{if .Data.Zones}}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Zone</th>
{{if $reverse}}<th>Kind</th>{{end}}
<th class="text-end">Records</th>
<th class="text-end">Serial</th>
<th class="text-end">TTL</th>
<th>Status</th>
<th>Updated</th>
<th class="row-actions">Actions</th>
</tr>
</thead>
<tbody>
{{range .Data.Zones}}
<tr {{if not .Enabled}}class="is-disabled"{{end}}>
<td>
<a href="/zones/{{.ID}}" class="fw-semibold text-decoration-none mono">{{trimDot .Name}}</a>
{{if .Description}}<div class="small text-body-secondary">{{truncate 80 .Description}}</div>{{end}}
</td>
{{if $reverse}}<td><span class="badge text-bg-light text-dark">{{.Kind.Label}}</span></td>{{end}}
<td class="text-end">{{num .RecordCount}}</td>
<td class="text-end mono small">{{.Serial}}</td>
<td class="text-end">{{.DefaultTTL}}</td>
<td>
<span class="badge {{badgeFor .Enabled}}">{{statusWord .Enabled}}</span>
</td>
<td class="text-body-secondary small">{{timeAgo .UpdatedAt}}</td>
<td class="row-actions">
<div class="btn-group">
<a href="/zones/{{.ID}}" class="btn btn-sm btn-outline-secondary" title="Records">
<i class="bi bi-list-ul"></i>
</a>
<a href="/zones/{{.ID}}/edit" class="btn btn-sm btn-outline-secondary" title="Edit zone">
<i class="bi bi-pencil"></i>
</a>
<a href="/zones/{{.ID}}/export" class="btn btn-sm btn-outline-secondary" title="Export zone file">
<i class="bi bi-download"></i>
</a>
{{template "postform" dict
"Action" (printf "/zones/%d/toggle" .ID) "CSRF" $csrf
"Fields" (dict "enabled" (boolstr (not .Enabled)))
"Icon" (toggleIcon .Enabled)
"Class" "btn btn-sm btn-outline-secondary"
"Title" (printf "%s this zone" (toggleVerb .Enabled))}}
{{template "confirmform" dict
"Action" (printf "/zones/%d/delete" .ID) "CSRF" $csrf
"Icon" "bi-trash"
"Message" (printf "Delete zone %s and all %d of its records? This cannot be undone." (trimDot .Name) .RecordCount)}}
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else if .Data.Search}}
{{template "empty" dict "Icon" "bi-search" "Title" "No zones match that search"
"Message" "Try a different term, or clear the search box to see every zone."}}
{{else}}
{{if $reverse}}
{{template "empty" dict "Icon" "bi-arrow-left-right" "Title" "No reverse zones yet"
"Message" "Reverse zones answer PTR lookups that turn an IP address back into a host name. Enter a subnet such as 192.168.1.0/24 and the correct in-addr.arpa zone name is worked out for you."}}
{{else}}
{{template "empty" dict "Icon" "bi-diagram-3" "Title" "No forward zones yet"
"Message" "A forward zone lets this server answer authoritatively for a domain such as internal.example.com, independently of the public DNS."}}
{{end}}
<div class="text-center pb-4">
<a href="/zones/new?kind={{if $reverse}}reverse4{{else}}forward{{end}}" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>Create the first zone
</a>
</div>
{{end}}
</div>
</div>
{{end}}
+184
View File
@@ -0,0 +1,184 @@
{{/* Shared building blocks used across pages. */}}
{{define "sidebar"}}
<nav class="sidebar-nav" aria-label="Main navigation">
<a class="sidebar-link {{if eq .Nav "dashboard"}}active{{end}}" href="/">
<i class="bi bi-speedometer2"></i><span>Dashboard</span>
</a>
<div class="sidebar-heading">DNS</div>
<a class="sidebar-link {{if eq .Nav "zones"}}active{{end}}" href="/zones">
<i class="bi bi-diagram-3"></i><span>Forward Zones</span>
</a>
<a class="sidebar-link {{if eq .Nav "zones-reverse"}}active{{end}}" href="/zones/reverse">
<i class="bi bi-arrow-left-right"></i><span>Reverse Zones</span>
</a>
<a class="sidebar-link {{if eq .Nav "records"}}active{{end}}" href="/records">
<i class="bi bi-list-columns-reverse"></i><span>Records</span>
</a>
<a class="sidebar-link {{if eq .Nav "resolver"}}active{{end}}" href="/resolver">
<i class="bi bi-globe2"></i><span>Resolver</span>
</a>
<a class="sidebar-link {{if eq .Nav "cache"}}active{{end}}" href="/cache">
<i class="bi bi-lightning-charge"></i><span>Cache</span>
</a>
<div class="sidebar-heading">Policies</div>
<a class="sidebar-link {{if eq .Nav "networks"}}active{{end}}" href="/policies/networks">
<i class="bi bi-router"></i><span>Client Networks</span>
</a>
<a class="sidebar-link {{if eq .Nav "blacklists"}}active{{end}}" href="/policies/blacklists">
<i class="bi bi-shield-slash"></i><span>Blacklists</span>
</a>
<a class="sidebar-link {{if eq .Nav "allowlists"}}active{{end}}" href="/policies/allowlists">
<i class="bi bi-shield-check"></i><span>Allowlists</span>
</a>
<a class="sidebar-link {{if eq .Nav "policies"}}active{{end}}" href="/policies">
<i class="bi bi-sliders"></i><span>Rules</span>
</a>
<div class="sidebar-heading">Operations</div>
<a class="sidebar-link {{if eq .Nav "querylog"}}active{{end}}" href="/querylog">
<i class="bi bi-journal-text"></i><span>Query Log</span>
</a>
<a class="sidebar-link {{if eq .Nav "audit"}}active{{end}}" href="/audit">
<i class="bi bi-clipboard-check"></i><span>Audit Log</span>
</a>
<a class="sidebar-link {{if eq .Nav "tools"}}active{{end}}" href="/tools">
<i class="bi bi-tools"></i><span>Tools</span>
</a>
<div class="sidebar-heading">Settings</div>
<a class="sidebar-link {{if eq .Nav "settings"}}active{{end}}" href="/settings/dns">
<i class="bi bi-gear"></i><span>Settings</span>
</a>
<a class="sidebar-link {{if eq .Nav "account"}}active{{end}}" href="/account">
<i class="bi bi-person-badge"></i><span>Account</span>
</a>
</nav>
{{end}}
{{/* Page header: title, optional subtitle and a slot for actions. */}}
{{define "pageheader"}}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 class="page-title">{{.Title}}</h1>
{{if .Subtitle}}<p class="text-body-secondary mb-0">{{.Subtitle}}</p>{{end}}
</div>
{{if .Actions}}<div class="d-flex flex-wrap gap-2">{{.Actions}}</div>{{end}}
</div>
{{end}}
{{/* A statistic tile. Pass: Label, Value, Icon, Sub, Tone. */}}
{{define "stat"}}
<div class="col-6 col-md-4 col-xl-3">
<div class="card stat-card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div class="stat-label">{{.Label}}</div>
{{if .Icon}}<i class="bi {{.Icon}} stat-icon {{with .Tone}}text-{{.}}{{end}}"></i>{{end}}
</div>
<div class="stat-value">{{.Value}}</div>
{{if .Sub}}<div class="stat-sub">{{.Sub}}</div>{{end}}
</div>
</div>
</div>
{{end}}
{{/* Empty state. Pass: Icon, Title, Message, optional Action (safe HTML). */}}
{{define "empty"}}
<div class="empty-state text-center py-5">
<i class="bi {{.Icon}} empty-icon"></i>
<h2 class="h5 mt-3 mb-2">{{.Title}}</h2>
<p class="text-body-secondary mx-auto" style="max-width: 34rem;">{{.Message}}</p>
{{if .Action}}<div class="mt-3">{{.Action}}</div>{{end}}
</div>
{{end}}
{{/* Pagination. Pass: P (pagination struct), Q (url.Values). */}}
{{define "pagination"}}
{{$p := .P}}{{$q := .Q}}
{{if gt $p.Pages 1}}
<nav class="d-flex flex-wrap justify-content-between align-items-center gap-2 mt-3" aria-label="Pagination">
<div class="text-body-secondary small">
Showing {{num $p.From}}{{num $p.To}} of {{num $p.Total}}
</div>
<ul class="pagination pagination-sm mb-0">
<li class="page-item {{if not $p.HasPrev}}disabled{{end}}">
<a class="page-link" href="{{withQuery $q "page" 1}}" aria-label="First page">&laquo;</a>
</li>
<li class="page-item {{if not $p.HasPrev}}disabled{{end}}">
<a class="page-link" href="{{withQuery $q "page" (sub $p.Page 1)}}" aria-label="Previous page">&lsaquo;</a>
</li>
{{range $n := pages $p.Page $p.Pages}}
<li class="page-item {{if eq $n $p.Page}}active{{end}}">
<a class="page-link" href="{{withQuery $q "page" $n}}">{{$n}}</a>
</li>
{{end}}
<li class="page-item {{if not $p.HasNext}}disabled{{end}}">
<a class="page-link" href="{{withQuery $q "page" (add $p.Page 1)}}" aria-label="Next page">&rsaquo;</a>
</li>
<li class="page-item {{if not $p.HasNext}}disabled{{end}}">
<a class="page-link" href="{{withQuery $q "page" $p.Pages}}" aria-label="Last page">&raquo;</a>
</li>
</ul>
</nav>
{{else if gt $p.Total 0}}
<div class="text-body-secondary small mt-3">{{num $p.Total}} result{{if ne $p.Total 1}}s{{end}}</div>
{{end}}
{{end}}
{{/* A destructive submit button that opens the shared confirmation modal.
Pass: Action, CSRF, Label, Message, optional Icon, Class, ReturnTo. */}}
{{define "confirmform"}}
<form method="post" action="{{.Action}}" class="d-inline js-confirm" data-confirm="{{.Message}}">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
{{if .ReturnTo}}<input type="hidden" name="return_to" value="{{.ReturnTo}}">{{end}}
{{range $k, $v := .Fields}}<input type="hidden" name="{{$k}}" value="{{$v}}">{{end}}
<button type="submit" class="{{if .Class}}{{.Class}}{{else}}btn btn-sm btn-outline-danger{{end}}"
{{if .Title}}title="{{.Title}}"{{end}}>
{{if .Icon}}<i class="bi {{.Icon}}"></i>{{end}}{{if .Label}} {{.Label}}{{end}}
</button>
</form>
{{end}}
{{/* A plain post button with no confirmation. Same fields as confirmform. */}}
{{define "postform"}}
<form method="post" action="{{.Action}}" class="d-inline">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
{{if .ReturnTo}}<input type="hidden" name="return_to" value="{{.ReturnTo}}">{{end}}
{{range $k, $v := .Fields}}<input type="hidden" name="{{$k}}" value="{{$v}}">{{end}}
<button type="submit" class="{{if .Class}}{{.Class}}{{else}}btn btn-sm btn-outline-secondary{{end}}"
{{if .Title}}title="{{.Title}}"{{end}}>
{{if .Icon}}<i class="bi {{.Icon}}"></i>{{end}}{{if .Label}} {{.Label}}{{end}}
</button>
</form>
{{end}}
{{/* Settings sub-navigation. Pass the active key as .Subnav. */}}
{{define "settingsnav"}}
<ul class="nav nav-pills settings-nav mb-4 flex-wrap gap-1">
<li class="nav-item"><a class="nav-link {{if eq .Subnav "dns"}}active{{end}}" href="/settings/dns">DNS</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Subnav "resolver"}}active{{end}}" href="/settings/resolver">Resolver</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Subnav "cache"}}active{{end}}" href="/settings/cache">Cache</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Subnav "http"}}active{{end}}" href="/settings/http">Web Server</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Subnav "logging"}}active{{end}}" href="/settings/logging">Logging</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Subnav "database"}}active{{end}}" href="/settings/database">Database</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Subnav "api"}}active{{end}}" href="/settings/api">API</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Subnav "account"}}active{{end}}" href="/account">Authentication</a></li>
</ul>
{{end}}
{{/* Marks a field that needs a restart to take effect. */}}
{{define "restartbadge"}}
<span class="badge text-bg-warning-subtle border border-warning-subtle text-warning-emphasis ms-1"
title="Changing this requires a restart">restart required</span>
{{end}}