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:
@@ -0,0 +1,56 @@
|
||||
package config
|
||||
|
||||
// DefaultBlockPageHTML is the block page shown for a blocked query answered
|
||||
// with the sinkhole action, before an operator customises it. It is a
|
||||
// self-contained document — no external stylesheets, fonts or scripts — since
|
||||
// it must render for a client that is, by definition, cut off from the rest
|
||||
// of the network. The placeholders are filled in per request; see the
|
||||
// "Available placeholders" reference on the block page settings screen.
|
||||
const DefaultBlockPageHTML = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Blocked — {{.Domain}}</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: #0f1115; color: #e6e8eb; padding: 24px;
|
||||
}
|
||||
.card {
|
||||
max-width: 560px; width: 100%; background: #171a21; border: 1px solid #2a2e37;
|
||||
border-radius: 12px; padding: 32px 36px; box-shadow: 0 10px 40px rgba(0,0,0,.35);
|
||||
}
|
||||
.icon {
|
||||
width: 56px; height: 56px; border-radius: 50%; background: #3a1d1d; color: #ff6b6b;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 28px; margin-bottom: 20px;
|
||||
}
|
||||
h1 { font-size: 22px; margin: 0 0 6px; }
|
||||
.domain { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: #ff8787; word-break: break-all; }
|
||||
p { color: #aab0bb; line-height: 1.5; }
|
||||
dl { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; margin: 20px 0 0; font-size: 14px; }
|
||||
dt { color: #7c828d; }
|
||||
dd { margin: 0; color: #e6e8eb; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.footer { margin-top: 24px; font-size: 12px; color: #565c68; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="icon">⚠</div>
|
||||
<h1>This domain is blocked</h1>
|
||||
<p><span class="domain">{{.Domain}}</span> was blocked by the network's DNS filtering policy.</p>
|
||||
<dl>
|
||||
<dt>Blocklist</dt><dd>{{.ListName}}</dd>
|
||||
<dt>Matched rule</dt><dd>{{.MatchedDomain}}</dd>
|
||||
<dt>Policy</dt><dd>{{.PolicyName}}</dd>
|
||||
<dt>Network</dt><dd>{{.NetworkName}}</dd>
|
||||
<dt>Your address</dt><dd>{{.ClientIP}}</dd>
|
||||
</dl>
|
||||
<div class="footer">Blocked at {{.Timestamp}} by VibeDNS</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
@@ -71,6 +71,11 @@ const (
|
||||
KeyLogLevel = "log.level"
|
||||
KeyLogFormat = "log.format"
|
||||
KeyAuditMaxRows = "log.audit_max_rows"
|
||||
|
||||
KeyBlockPageEnabled = "blockpage.enabled"
|
||||
KeyBlockPageHTTPListen = "blockpage.http_listen"
|
||||
KeyBlockPageHTTPSListen = "blockpage.https_listen"
|
||||
KeyBlockPageHTML = "blockpage.html"
|
||||
)
|
||||
|
||||
// DNSSettings covers the listeners and protocol behaviour.
|
||||
@@ -165,6 +170,15 @@ type LoggingSettings struct {
|
||||
AuditMaxRows int `json:"audit_max_rows"`
|
||||
}
|
||||
|
||||
// BlockPageSettings covers the HTTP/HTTPS server that answers sinkholed
|
||||
// traffic with a page explaining why the request was blocked.
|
||||
type BlockPageSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
HTTPListen string `json:"http_listen"`
|
||||
HTTPSListen string `json:"https_listen"`
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
|
||||
// Settings is the complete runtime configuration held in SQLite.
|
||||
type Settings struct {
|
||||
DNS DNSSettings `json:"dns"`
|
||||
@@ -175,6 +189,7 @@ type Settings struct {
|
||||
HTTP HTTPSettings `json:"http"`
|
||||
Backup BackupSettings `json:"backup"`
|
||||
Logging LoggingSettings `json:"logging"`
|
||||
BlockPage BlockPageSettings `json:"block_page"`
|
||||
}
|
||||
|
||||
// DefaultSettings returns a safe, closed-by-default configuration.
|
||||
@@ -247,6 +262,12 @@ func DefaultSettings() Settings {
|
||||
Format: "text",
|
||||
AuditMaxRows: 50_000,
|
||||
},
|
||||
BlockPage: BlockPageSettings{
|
||||
Enabled: false,
|
||||
HTTPListen: ":80",
|
||||
HTTPSListen: ":443",
|
||||
HTML: DefaultBlockPageHTML,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,6 +353,11 @@ func LoadSettings(stored map[string]string) Settings {
|
||||
s.Logging.Format = g.str(KeyLogFormat, s.Logging.Format)
|
||||
s.Logging.AuditMaxRows = g.integer(KeyAuditMaxRows, s.Logging.AuditMaxRows)
|
||||
|
||||
s.BlockPage.Enabled = g.boolean(KeyBlockPageEnabled, s.BlockPage.Enabled)
|
||||
s.BlockPage.HTTPListen = g.str(KeyBlockPageHTTPListen, s.BlockPage.HTTPListen)
|
||||
s.BlockPage.HTTPSListen = g.str(KeyBlockPageHTTPSListen, s.BlockPage.HTTPSListen)
|
||||
s.BlockPage.HTML = g.str(KeyBlockPageHTML, s.BlockPage.HTML)
|
||||
|
||||
s.Normalise()
|
||||
return s
|
||||
}
|
||||
@@ -398,6 +424,11 @@ func (s Settings) ToMap() map[string]string {
|
||||
KeyLogLevel: s.Logging.Level,
|
||||
KeyLogFormat: s.Logging.Format,
|
||||
KeyAuditMaxRows: itoa(s.Logging.AuditMaxRows),
|
||||
|
||||
KeyBlockPageEnabled: boolStr(s.BlockPage.Enabled),
|
||||
KeyBlockPageHTTPListen: s.BlockPage.HTTPListen,
|
||||
KeyBlockPageHTTPSListen: s.BlockPage.HTTPSListen,
|
||||
KeyBlockPageHTML: s.BlockPage.HTML,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,6 +493,10 @@ func (s *Settings) Normalise() {
|
||||
s.Logging.Format = "text"
|
||||
}
|
||||
s.Logging.AuditMaxRows = clamp(s.Logging.AuditMaxRows, 100, 10_000_000)
|
||||
|
||||
if strings.TrimSpace(s.BlockPage.HTML) == "" {
|
||||
s.BlockPage.HTML = DefaultBlockPageHTML
|
||||
}
|
||||
}
|
||||
|
||||
// Validate reports configuration errors that should be shown to the operator
|
||||
@@ -509,6 +544,22 @@ func (s Settings) Validate() error {
|
||||
if s.Backup.Enabled && strings.TrimSpace(s.Backup.Directory) == "" {
|
||||
return fmt.Errorf("backups are enabled but no backup directory is set")
|
||||
}
|
||||
if s.BlockPage.Enabled {
|
||||
if err := validateListenAddr(s.BlockPage.HTTPListen); err != nil {
|
||||
return fmt.Errorf("block page HTTP listen address: %w", err)
|
||||
}
|
||||
if err := validateListenAddr(s.BlockPage.HTTPSListen); err != nil {
|
||||
return fmt.Errorf("block page HTTPS listen address: %w", err)
|
||||
}
|
||||
if s.BlockPage.HTTPListen == s.BlockPage.HTTPSListen {
|
||||
return fmt.Errorf("block page HTTP and HTTPS listen addresses must differ")
|
||||
}
|
||||
for _, other := range []string{s.HTTP.Listen, s.DNS.UDPListen, s.DNS.TCPListen} {
|
||||
if s.BlockPage.HTTPListen == other || s.BlockPage.HTTPSListen == other {
|
||||
return fmt.Errorf("block page listen address %q collides with another listener", other)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user