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
+51
View File
@@ -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
}