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
+52
View File
@@ -0,0 +1,52 @@
package web
import (
"fmt"
"html"
"net/http"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/blockpage"
)
func (s *Server) handleSettingsBlockPage(w http.ResponseWriter, r *http.Request) error {
boundHTTP, boundHTTPS := s.app.BlockPage.ListenAddrs()
s.settingsPage(w, r, "settings_blockpage", "blockpage", "Block Page Settings", map[string]any{
"Running": s.app.BlockPage.Running(),
"BoundHTTP": boundHTTP,
"BoundHTTPS": boundHTTPS,
})
return nil
}
func (s *Server) handleSettingsBlockPageSave(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
next := s.app.Settings()
next.BlockPage.Enabled = formBool(r, "enabled")
next.BlockPage.HTTPListen = formString(r, "http_listen")
next.BlockPage.HTTPSListen = formString(r, "https_listen")
next.BlockPage.HTML = r.FormValue("html")
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupBlockPage, next); err != nil {
return err
}
setFlash(w, r, "success", "Block page settings saved.")
return s.redirect(w, r, "/settings/blockpage")
}
// handleSettingsBlockPagePreview renders the HTML in the request body with
// sample data, without saving it, so the editor can show a live preview.
func (s *Server) handleSettingsBlockPagePreview(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
out, err := blockpage.Render(r.FormValue("html"))
if err != nil {
out = fmt.Sprintf("<pre style=\"white-space:pre-wrap;font-family:monospace;color:#c00;padding:1em\">Template error: %s</pre>", html.EscapeString(err.Error()))
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(out))
return nil
}
+3
View File
@@ -223,6 +223,9 @@ func (s *Server) routes(mux *http.ServeMux) {
mux.Handle("POST /settings/database/backup/{name}/delete", page(s.handleBackupDelete))
mux.Handle("POST /settings/database/backup/{name}/restore", page(s.handleBackupRestore))
mux.Handle("POST /settings/database/restore/cancel", page(s.handleRestoreCancel))
mux.Handle("GET /settings/blockpage", page(s.handleSettingsBlockPage))
mux.Handle("POST /settings/blockpage", page(s.handleSettingsBlockPageSave))
mux.Handle("POST /settings/blockpage/preview", page(s.handleSettingsBlockPagePreview))
mux.Handle("GET /settings/api", page(s.handleSettingsAPI))
mux.Handle("POST /settings/api/tokens", page(s.handleTokenCreate))
mux.Handle("POST /settings/api/tokens/{id}/delete", page(s.handleTokenDelete))