// 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 = `
The custom block page template is misconfigured; showing a default page instead. Check the HTML under Settings → Block Page.
` // 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 }