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