initial commit

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