Files
vibedns/internal/blockpage/server.go
T
owenandClaude Sonnet 5 4895c8fd1e 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
2026-08-17 00:24:00 -05:00

276 lines
7.5 KiB
Go

// Package blockpage serves an HTTP(S) page explaining why a domain was
// blocked, for clients whose blocked queries are answered with a sinkhole
// address (see internal/policy). It binds its own listeners, separate from
// the DNS server and the management interface, since it typically needs to
// live on the standard web ports 80 and 443.
package blockpage
import (
"context"
"crypto/tls"
"errors"
"fmt"
"html/template"
"log/slog"
"net"
"net/http"
"net/netip"
"strings"
"sync"
"time"
"github.com/owen/vibedns/internal/netutil"
"github.com/owen/vibedns/internal/runtimecfg"
)
// pageData is what the operator's HTML template can reference.
type pageData struct {
Domain string
MatchedDomain string
ListName string
PolicyName string
NetworkName string
ClientIP string
RequestPath string
Scheme string
Timestamp string
}
// fallbackHTML is served when the configured template fails to parse, so a
// typo in the editor never turns the block page into a blank 500.
const fallbackHTML = `<!doctype html><meta charset="utf-8">
<title>Blocked</title>
<body style="font-family:sans-serif;max-width:40em;margin:4em auto">
<h1>This domain is blocked</h1>
<p>The custom block page template is misconfigured; showing a default page
instead. Check the HTML under Settings &rarr; Block Page.</p>
</body>`
// Server runs the block page's HTTP and HTTPS listeners.
type Server struct {
runtime *runtimecfg.Manager
log *slog.Logger
tmplMu sync.Mutex
tmplSrc string
tmpl *template.Template
certs certStore
mu sync.Mutex
running bool
httpSrv *http.Server
httpsSrv *http.Server
httpAddr string
httpsAddr string
wg sync.WaitGroup
}
// New creates a block page server. Call Start to bind the listeners.
func New(rt *runtimecfg.Manager, log *slog.Logger) *Server {
return &Server{
runtime: rt,
log: log,
certs: newCertStore(),
}
}
// Start binds the HTTP and HTTPS listeners. It returns once both are
// accepting connections, or with an error naming which one could not bind.
func (s *Server) Start(httpAddr, httpsAddr string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
return errors.New("block page server is already running")
}
handler := http.HandlerFunc(s.handle)
httpLn, err := net.Listen("tcp", httpAddr)
if err != nil {
return fmt.Errorf("block page HTTP listener on %s: %w", httpAddr, describeBindError(err))
}
httpsLn, err := net.Listen("tcp", httpsAddr)
if err != nil {
httpLn.Close()
return fmt.Errorf("block page HTTPS listener on %s: %w", httpsAddr, describeBindError(err))
}
tlsLn := tls.NewListener(httpsLn, &tls.Config{GetCertificate: s.certs.get})
s.httpAddr = httpAddr
s.httpsAddr = httpsAddr
s.httpSrv = &http.Server{Handler: handler, ReadHeaderTimeout: 10 * time.Second}
s.httpsSrv = &http.Server{Handler: handler, ReadHeaderTimeout: 10 * time.Second}
s.wg.Add(2)
go func() {
defer s.wg.Done()
if err := s.httpSrv.Serve(httpLn); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Error("block page HTTP listener stopped", "error", err)
}
}()
go func() {
defer s.wg.Done()
if err := s.httpsSrv.Serve(tlsLn); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Error("block page HTTPS listener stopped", "error", err)
}
}()
s.running = true
s.log.Info("block page listeners started", "http", httpAddr, "https", httpsAddr)
return nil
}
// describeBindError turns a raw bind failure into something actionable.
func describeBindError(err error) error {
msg := err.Error()
switch {
case strings.Contains(msg, "permission denied"):
return fmt.Errorf("%w (binding a port below 1024 needs root, or grant the "+
"binary CAP_NET_BIND_SERVICE with: setcap 'cap_net_bind_service=+ep' ./vibedns)", err)
case strings.Contains(msg, "address already in use"):
return fmt.Errorf("%w (something else is already listening on that address)", err)
default:
return err
}
}
// Shutdown stops both listeners.
func (s *Server) Shutdown(ctx context.Context) error {
s.mu.Lock()
httpSrv, httpsSrv, running := s.httpSrv, s.httpsSrv, s.running
s.running = false
s.mu.Unlock()
if !running {
return nil
}
var firstErr error
if httpSrv != nil {
if err := httpSrv.Shutdown(ctx); err != nil && firstErr == nil {
firstErr = err
}
}
if httpsSrv != nil {
if err := httpsSrv.Shutdown(ctx); err != nil && firstErr == nil {
firstErr = err
}
}
s.wg.Wait()
s.log.Info("block page listeners stopped")
return firstErr
}
// Running reports whether the listeners are up.
func (s *Server) Running() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.running
}
// ListenAddrs returns the bound addresses for the status page.
func (s *Server) ListenAddrs() (http, https string) {
s.mu.Lock()
defer s.mu.Unlock()
return s.httpAddr, s.httpsAddr
}
// handle serves both the HTTP and HTTPS listeners.
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
snap := s.runtime.Current()
clientIP := "unknown"
if addr, ok := netutil.AddrFromHostPort(r.RemoteAddr); ok {
clientIP = addr.String()
}
hostname := r.Host
if r.TLS != nil && r.TLS.ServerName != "" {
hostname = r.TLS.ServerName
}
if h, _, err := net.SplitHostPort(hostname); err == nil {
hostname = h
}
hostname = strings.ToLower(strings.TrimSuffix(hostname, "."))
data := pageData{
Domain: hostname,
ListName: "unknown",
PolicyName: "unknown",
NetworkName: "unknown",
ClientIP: clientIP,
RequestPath: r.URL.Path,
Timestamp: time.Now().Format(time.RFC1123),
}
if r.TLS != nil {
data.Scheme = "https"
} else {
data.Scheme = "http"
}
if addr, err := netip.ParseAddr(clientIP); err == nil && hostname != "" && snap.Policy != nil {
d := snap.Policy.Evaluate(addr, hostname)
if d.Blocked {
data.ListName = d.ListName
data.MatchedDomain = d.MatchedDomain
data.PolicyName = d.PolicyName()
data.NetworkName = d.NetworkName()
}
}
tmpl := s.compiled(snap.Settings.BlockPage.HTML)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
if err := tmpl.Execute(w, data); err != nil {
s.log.Warn("could not render the block page template", "error", err)
}
}
// compiled returns the template compiled from src, recompiling only when src
// has changed since the last request.
func (s *Server) compiled(src string) *template.Template {
s.tmplMu.Lock()
defer s.tmplMu.Unlock()
if s.tmpl != nil && s.tmplSrc == src {
return s.tmpl
}
t, err := template.New("blockpage").Parse(src)
if err != nil {
s.log.Warn("block page template is invalid; showing the fallback page", "error", err)
t = template.Must(template.New("blockpage-fallback").Parse(fallbackHTML))
// Do not cache the broken source as tmplSrc, so a fix takes effect on
// the very next reload rather than needing a second edit to "change".
s.tmpl = t
return t
}
s.tmpl = t
s.tmplSrc = src
return t
}
// Render renders src with sample data, for the settings page preview. It
// never touches the running configuration.
func Render(src string) (string, error) {
t, err := template.New("preview").Parse(src)
if err != nil {
return "", err
}
var b strings.Builder
data := pageData{
Domain: "ads.example-tracker.com",
MatchedDomain: "example-tracker.com",
ListName: "Advertising",
PolicyName: "Default Protection",
NetworkName: "Private IPv4 192.168.0.0/16",
ClientIP: "192.168.1.42",
RequestPath: "/",
Scheme: "https",
Timestamp: time.Now().Format(time.RFC1123),
}
if err := t.Execute(&b, data); err != nil {
return "", err
}
return b.String(), nil
}