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
+27 -13
View File
@@ -12,6 +12,7 @@ import (
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/auth"
"github.com/owen/vibedns/internal/backup"
"github.com/owen/vibedns/internal/blockpage"
"github.com/owen/vibedns/internal/cache"
"github.com/owen/vibedns/internal/config"
"github.com/owen/vibedns/internal/database"
@@ -30,19 +31,20 @@ const keyCSRFSecret = "security.csrf_key"
// App wires every component together and exposes the operations the
// management interface performs.
type App struct {
Boot config.Bootstrap
DB *database.DB
Runtime *runtimecfg.Manager
Cache *cache.Cache
Resolver *resolver.Resolver
Limiter *ratelimit.Limiter
Metrics *metrics.Metrics
QueryLog *querylog.Logger
Audit *auditlog.Logger
Auth *auth.Authenticator
Backups *backup.Manager
DNS *dnsengine.Server
Log *slog.Logger
Boot config.Bootstrap
DB *database.DB
Runtime *runtimecfg.Manager
Cache *cache.Cache
Resolver *resolver.Resolver
Limiter *ratelimit.Limiter
Metrics *metrics.Metrics
QueryLog *querylog.Logger
Audit *auditlog.Logger
Auth *auth.Authenticator
Backups *backup.Manager
DNS *dnsengine.Server
BlockPage *blockpage.Server
Log *slog.Logger
cancel context.CancelFunc
started time.Time
@@ -88,6 +90,7 @@ func New(ctx context.Context, boot config.Bootstrap, db *database.DB, log *slog.
QueryLog: a.QueryLog,
Log: log,
})
a.BlockPage = blockpage.New(rt, log)
// Every subsystem picks up new settings from the same reload event, so a
// change in the UI takes effect without a restart.
@@ -203,12 +206,23 @@ func (a *App) Start(ctx context.Context) error {
a.cancel()
return err
}
if settings := a.Runtime.Settings(); settings.BlockPage.Enabled {
if err := a.BlockPage.Start(settings.BlockPage.HTTPListen, settings.BlockPage.HTTPSListen); err != nil {
a.cancel()
_ = a.DNS.Shutdown(context.Background())
return err
}
}
return nil
}
// Shutdown stops the DNS listeners and drains the background workers.
func (a *App) Shutdown(ctx context.Context) error {
err := a.DNS.Shutdown(ctx)
if bpErr := a.BlockPage.Shutdown(ctx); bpErr != nil && err == nil {
err = bpErr
}
if a.cancel != nil {
a.cancel()
}
+119
View File
@@ -0,0 +1,119 @@
package app_test
import (
"context"
"crypto/tls"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/owen/vibedns/internal/app"
)
// TestBlockPageServesMatchedPolicy exercises the whole path an operator sets
// up: a blacklist, a policy that sinkholes to this host, and a network that
// covers the test client. Hitting the block page listener for the blocked
// hostname should re-evaluate the same policy the DNS engine would have used,
// and the rendered page should carry the matched list and policy names.
func TestBlockPageServesMatchedPolicy(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
list, err := a.CreateDomainList(ctx, testActor(), app.ListInput{
Kind: "blacklist", Name: "Test Ads",
})
if err != nil {
t.Fatalf("create list: %v", err)
}
if _, err := a.AddDomain(ctx, testActor(), list.ID, "ads.example.com", true, ""); err != nil {
t.Fatalf("add domain: %v", err)
}
policy, err := a.CreatePolicy(ctx, testActor(), app.PolicyInput{
Name: "Block Test",
BlockAction: "sinkhole",
SinkholeIPv4: "127.0.0.1",
SinkholeIPv6: "::1",
ListIDs: []int64{list.ID},
})
if err != nil {
t.Fatalf("create policy: %v", err)
}
if _, err := a.CreateNetwork(ctx, testActor(), app.NetworkInput{
Name: "Test Client", CIDR: "127.0.0.1/32", PolicyIDs: []int64{policy.ID},
}); err != nil {
t.Fatalf("create network: %v", err)
}
if err := a.Reload(ctx); err != nil {
t.Fatalf("reload: %v", err)
}
const httpAddr = "127.0.0.1:18280"
const httpsAddr = "127.0.0.1:18243"
if err := a.BlockPage.Start(httpAddr, httpsAddr); err != nil {
t.Fatalf("start block page server: %v", err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = a.BlockPage.Shutdown(ctx)
})
client := &http.Client{
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
Timeout: 5 * time.Second,
}
// Wait for the listener to actually accept, rather than sleeping blindly.
deadline := time.Now().Add(2 * time.Second)
for {
if a.BlockPage.Running() {
break
}
if time.Now().After(deadline) {
t.Fatal("block page server never reported running")
}
time.Sleep(10 * time.Millisecond)
}
req, err := http.NewRequest(http.MethodGet, "http://"+httpAddr+"/tracker.js", nil)
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Host = "ads.example.com"
resp, err := client.Do(req)
if err != nil {
t.Fatalf("GET block page: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
page := string(body)
if !strings.Contains(page, "Test Ads") {
t.Errorf("page does not mention the matched list name: %s", page)
}
if !strings.Contains(page, "Block Test") {
t.Errorf("page does not mention the matched policy name: %s", page)
}
if !strings.Contains(page, "ads.example.com") {
t.Errorf("page does not mention the requested domain: %s", page)
}
httpsResp, err := client.Get("https://" + httpsAddr + "/")
if err != nil {
t.Fatalf("GET https block page: %v", err)
}
defer httpsResp.Body.Close()
if httpsResp.StatusCode != http.StatusOK {
t.Fatalf("https status = %d, want 200", httpsResp.StatusCode)
}
}
+17 -3
View File
@@ -13,9 +13,12 @@ import (
// RestartRequired lists the settings that only take effect after a restart,
// because they control a bound socket.
var RestartRequired = map[string]string{
config.KeyDNSUDPListen: "DNS UDP listen address",
config.KeyDNSTCPListen: "DNS TCP listen address",
config.KeyHTTPListen: "Management HTTP listen address",
config.KeyDNSUDPListen: "DNS UDP listen address",
config.KeyDNSTCPListen: "DNS TCP listen address",
config.KeyHTTPListen: "Management HTTP listen address",
config.KeyBlockPageEnabled: "Block page enabled state",
config.KeyBlockPageHTTPListen: "Block page HTTP listen address",
config.KeyBlockPageHTTPSListen: "Block page HTTPS listen address",
}
// SettingsGroup names a page of the settings interface.
@@ -30,6 +33,7 @@ const (
GroupHTTP SettingsGroup = "http"
GroupBackup SettingsGroup = "backup"
GroupRateLimit SettingsGroup = "ratelimit"
GroupBlockPage SettingsGroup = "blockpage"
)
// SaveSettings validates and persists a complete settings object.
@@ -96,6 +100,16 @@ func (a *App) PendingRestart(ctx context.Context) []string {
pending = append(pending, fmt.Sprintf("DNS TCP address (listening on %s, configured as %s)",
tcp, current.DNS.TCPListen))
}
bpHTTP, bpHTTPS := a.BlockPage.ListenAddrs()
bpRunning := a.BlockPage.Running()
if current.BlockPage.Enabled != bpRunning {
pending = append(pending, "Block page enabled state has changed")
} else if bpRunning && (current.BlockPage.HTTPListen != bpHTTP || current.BlockPage.HTTPSListen != bpHTTPS) {
pending = append(pending, fmt.Sprintf(
"Block page address (listening on %s/%s, configured as %s/%s)",
bpHTTP, bpHTTPS, current.BlockPage.HTTPListen, current.BlockPage.HTTPSListen))
}
return pending
}