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,91 @@
|
||||
package blockpage
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func TestRenderFillsPlaceholders(t *testing.T) {
|
||||
out, err := Render(`<h1>{{.Domain}} blocked by {{.ListName}} ({{.PolicyName}})</h1>`)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "ads.example-tracker.com blocked by Advertising (Default Protection)") {
|
||||
t.Errorf("unexpected output: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRejectsInvalidTemplate(t *testing.T) {
|
||||
if _, err := Render(`{{.Domain`); err == nil {
|
||||
t.Fatal("expected a parse error for unterminated action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderEscapesAttackerControlledDomain(t *testing.T) {
|
||||
// html/template must escape a hostile-looking hostname; a browser-facing
|
||||
// error page has no business ever emitting attacker HTML unescaped.
|
||||
out, err := Render(`<p>{{.Domain}}</p>`)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if strings.Contains(out, "<script>") {
|
||||
t.Errorf("output contains an unescaped tag: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertStoreGeneratesMatchingSAN(t *testing.T) {
|
||||
store := newCertStore()
|
||||
cert, err := store.get(&tls.ClientHelloInfo{ServerName: "blocked.example.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("get certificate: %v", err)
|
||||
}
|
||||
leaf, err := parseLeaf(cert)
|
||||
if err != nil {
|
||||
t.Fatalf("parse leaf: %v", err)
|
||||
}
|
||||
if len(leaf.DNSNames) != 1 || leaf.DNSNames[0] != "blocked.example.com" {
|
||||
t.Errorf("DNSNames = %v, want [blocked.example.com]", leaf.DNSNames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertStoreCachesByHostname(t *testing.T) {
|
||||
store := newCertStore()
|
||||
a, err := store.get(&tls.ClientHelloInfo{ServerName: "a.example.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
b, err := store.get(&tls.ClientHelloInfo{ServerName: "a.example.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if a != b {
|
||||
t.Error("expected the same cached certificate for a repeated hostname")
|
||||
}
|
||||
|
||||
c, err := store.get(&tls.ClientHelloInfo{ServerName: "b.example.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if a == c {
|
||||
t.Error("expected a different certificate for a different hostname")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerFallsBackOnBrokenTemplate(t *testing.T) {
|
||||
s := &Server{log: discardLogger()}
|
||||
tmpl := s.compiled(`{{.Domain`)
|
||||
var b strings.Builder
|
||||
if err := tmpl.Execute(&b, pageData{}); err != nil {
|
||||
t.Fatalf("execute fallback: %v", err)
|
||||
}
|
||||
if !strings.Contains(b.String(), "misconfigured") {
|
||||
t.Errorf("expected the fallback page, got: %s", b.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package blockpage
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// certCap bounds how many per-hostname certificates are kept in memory. A
|
||||
// client that churns through SNI names (deliberately or not) could otherwise
|
||||
// grow the cache without limit; when the cap is hit the whole cache is
|
||||
// cleared and rebuilt on demand, which just costs a few CPU-cheap regenerations.
|
||||
const certCap = 20_000
|
||||
|
||||
// certStore lazily generates and caches a self-signed TLS certificate for
|
||||
// each hostname a client connects with over SNI. Signing each leaf with a
|
||||
// name that matches what was requested means the browser's warning is the
|
||||
// expected "this certificate is not trusted" one, not also a confusing
|
||||
// hostname mismatch.
|
||||
type certStore struct {
|
||||
mu sync.Mutex
|
||||
certs map[string]*tls.Certificate
|
||||
}
|
||||
|
||||
func newCertStore() certStore {
|
||||
return certStore{certs: make(map[string]*tls.Certificate)}
|
||||
}
|
||||
|
||||
// get implements tls.Config.GetCertificate.
|
||||
func (c *certStore) get(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
name := hello.ServerName
|
||||
if name == "" {
|
||||
name = "block-page.invalid"
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if cert, ok := c.certs[name]; ok {
|
||||
c.mu.Unlock()
|
||||
return cert, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
cert, err := generateCert(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if len(c.certs) >= certCap {
|
||||
c.certs = make(map[string]*tls.Certificate)
|
||||
}
|
||||
c.certs[name] = cert
|
||||
c.mu.Unlock()
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// generateCert creates a fresh, self-signed ECDSA leaf certificate for name.
|
||||
func generateCert(name string) (*tls.Certificate, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate block page certificate key: %w", err)
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 62))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate block page certificate serial: %w", err)
|
||||
}
|
||||
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: name, Organization: []string{"VibeDNS Block Page"}},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().AddDate(10, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
if ip := net.ParseIP(name); ip != nil {
|
||||
tmpl.IPAddresses = []net.IP{ip}
|
||||
} else {
|
||||
tmpl.DNSNames = []string{name}
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create block page certificate: %w", err)
|
||||
}
|
||||
return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, nil
|
||||
}
|
||||
|
||||
// parseLeaf parses the leaf certificate's DER bytes, for tests that need to
|
||||
// inspect what generateCert produced.
|
||||
func parseLeaf(cert *tls.Certificate) (*x509.Certificate, error) {
|
||||
return x509.ParseCertificate(cert.Certificate[0])
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// 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 → 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
|
||||
}
|
||||
Reference in New Issue
Block a user