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
276 lines
7.5 KiB
Go
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 → 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
|
|
}
|