initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
+5
File diff suppressed because one or more lines are too long
Vendored
+6
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -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 |
@@ -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();
|
||||
});
|
||||
})();
|
||||
+7
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}} $TTL 3600 @ 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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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 5–50 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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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">«</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">‹</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">›</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">»</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}}
|
||||
Reference in New Issue
Block a user