Add a customizable HTTP/HTTPS block page for sinkholed queries

Serves a page explaining why a domain was blocked instead of leaving a
sinkholed client with a dead connection. Binds its own HTTP/HTTPS
listeners with self-signed, per-hostname TLS certs generated on the
fly, re-evaluates the requesting client against the policy engine per
request, and renders an HTML template editable from Settings with a
live preview.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TTKpGMQzpfDsvedu1hvSUf
This commit is contained in:
2026-08-17 00:24:00 -05:00
co-authored by Claude Sonnet 5
parent 566a5fd3ed
commit 4895c8fd1e
13 changed files with 948 additions and 16 deletions
+121
View File
@@ -0,0 +1,121 @@
{{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/blockpage" data-preview-url="/settings/blockpage/preview">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="row g-3">
<div class="col-12 col-xl-4">
<div class="card mb-3">
<div class="card-header">Block page server</div>
<div class="card-body">
<div class="form-check form-switch mb-3">
<input type="hidden" name="enabled" value="false">
<input class="form-check-input" type="checkbox" role="switch" id="bpEnabled"
name="enabled" value="true" {{if $s.BlockPage.Enabled}}checked{{end}}>
<label class="form-check-label" for="bpEnabled">
Serve a block page on HTTP/HTTPS {{template "restartbadge"}}
</label>
</div>
<div class="form-text mb-3">
Point a policy's sinkhole address at this host, and a browser that
lands here instead of the real site sees the page below. Ports
below 1024 need root or
<span class="mono">CAP_NET_BIND_SERVICE</span>.
</div>
<label class="form-label" for="bpHTTP">HTTP listen address</label>
<input type="text" class="form-control mono mb-3" id="bpHTTP" name="http_listen"
value="{{$s.BlockPage.HTTPListen}}" placeholder=":80" required>
<label class="form-label" for="bpHTTPS">HTTPS listen address</label>
<input type="text" class="form-control mono mb-3" id="bpHTTPS" name="https_listen"
value="{{$s.BlockPage.HTTPSListen}}" placeholder=":443" required>
<div class="form-text">
HTTPS is served with a self-signed certificate generated per
hostname on the fly — browsers will show a trust warning, which
is expected: this is not the real site's certificate.
</div>
<hr class="my-3">
<div class="d-flex align-items-center gap-2 mb-2">
<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">HTTP</dt>
<dd class="col-7 mono">{{if .Data.BoundHTTP}}{{.Data.BoundHTTP}}{{else}}&mdash;{{end}}</dd>
<dt class="col-5 text-body-secondary">HTTPS</dt>
<dd class="col-7 mono">{{if .Data.BoundHTTPS}}{{.Data.BoundHTTPS}}{{else}}&mdash;{{end}}</dd>
</dl>
</div>
</div>
<div class="card">
<div class="card-header">Available placeholders</div>
<div class="card-body">
<p class="text-body-secondary small mb-2">
Filled in per request by re-evaluating the requesting client
against the policy engine, the same way a DNS query would be.
</p>
<dl class="row small mb-0">
<dt class="col-5 mono">{{"{{.Domain}}"}}</dt>
<dd class="col-7 text-body-secondary">Requested hostname</dd>
<dt class="col-5 mono">{{"{{.ListName}}"}}</dt>
<dd class="col-7 text-body-secondary">Matched blocklist name</dd>
<dt class="col-5 mono">{{"{{.MatchedDomain}}"}}</dt>
<dd class="col-7 text-body-secondary">Matched list entry</dd>
<dt class="col-5 mono">{{"{{.PolicyName}}"}}</dt>
<dd class="col-7 text-body-secondary">Matched policy name</dd>
<dt class="col-5 mono">{{"{{.NetworkName}}"}}</dt>
<dd class="col-7 text-body-secondary">Matched client network</dd>
<dt class="col-5 mono">{{"{{.ClientIP}}"}}</dt>
<dd class="col-7 text-body-secondary">Requesting address</dd>
<dt class="col-5 mono">{{"{{.RequestPath}}"}}</dt>
<dd class="col-7 text-body-secondary">URL path requested</dd>
<dt class="col-5 mono">{{"{{.Scheme}}"}}</dt>
<dd class="col-7 text-body-secondary">http or https</dd>
<dt class="col-5 mono">{{"{{.Timestamp}}"}}</dt>
<dd class="col-7 text-body-secondary">Time of the request</dd>
</dl>
</div>
</div>
</div>
<div class="col-12 col-xl-8">
<div class="card mb-3">
<div class="card-header">HTML editor</div>
<div class="card-body">
<textarea class="form-control mono" id="blockPageHTML" name="html" rows="22"
spellcheck="false" style="font-size:.85rem">{{$s.BlockPage.HTML}}</textarea>
<div class="form-text">
Standard Go template syntax. A broken template falls back to a
plain built-in page rather than breaking the listener.
</div>
</div>
</div>
<div class="card">
<div class="card-header">Preview</div>
<div class="card-body p-0">
<iframe id="blockPagePreview" title="Block page preview"
style="width:100%;height:480px;border:0;border-radius:0 0 .5rem .5rem;background:#fff"></iframe>
</div>
</div>
</div>
</div>
<div class="form-actions mt-3">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save block page settings
</button>
</div>
</form>
{{end}}