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:
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/owen/vibedns/internal/auditlog"
|
"github.com/owen/vibedns/internal/auditlog"
|
||||||
"github.com/owen/vibedns/internal/auth"
|
"github.com/owen/vibedns/internal/auth"
|
||||||
"github.com/owen/vibedns/internal/backup"
|
"github.com/owen/vibedns/internal/backup"
|
||||||
|
"github.com/owen/vibedns/internal/blockpage"
|
||||||
"github.com/owen/vibedns/internal/cache"
|
"github.com/owen/vibedns/internal/cache"
|
||||||
"github.com/owen/vibedns/internal/config"
|
"github.com/owen/vibedns/internal/config"
|
||||||
"github.com/owen/vibedns/internal/database"
|
"github.com/owen/vibedns/internal/database"
|
||||||
@@ -42,6 +43,7 @@ type App struct {
|
|||||||
Auth *auth.Authenticator
|
Auth *auth.Authenticator
|
||||||
Backups *backup.Manager
|
Backups *backup.Manager
|
||||||
DNS *dnsengine.Server
|
DNS *dnsengine.Server
|
||||||
|
BlockPage *blockpage.Server
|
||||||
Log *slog.Logger
|
Log *slog.Logger
|
||||||
|
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
@@ -88,6 +90,7 @@ func New(ctx context.Context, boot config.Bootstrap, db *database.DB, log *slog.
|
|||||||
QueryLog: a.QueryLog,
|
QueryLog: a.QueryLog,
|
||||||
Log: log,
|
Log: log,
|
||||||
})
|
})
|
||||||
|
a.BlockPage = blockpage.New(rt, log)
|
||||||
|
|
||||||
// Every subsystem picks up new settings from the same reload event, so a
|
// Every subsystem picks up new settings from the same reload event, so a
|
||||||
// change in the UI takes effect without a restart.
|
// change in the UI takes effect without a restart.
|
||||||
@@ -203,12 +206,23 @@ func (a *App) Start(ctx context.Context) error {
|
|||||||
a.cancel()
|
a.cancel()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if settings := a.Runtime.Settings(); settings.BlockPage.Enabled {
|
||||||
|
if err := a.BlockPage.Start(settings.BlockPage.HTTPListen, settings.BlockPage.HTTPSListen); err != nil {
|
||||||
|
a.cancel()
|
||||||
|
_ = a.DNS.Shutdown(context.Background())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shutdown stops the DNS listeners and drains the background workers.
|
// Shutdown stops the DNS listeners and drains the background workers.
|
||||||
func (a *App) Shutdown(ctx context.Context) error {
|
func (a *App) Shutdown(ctx context.Context) error {
|
||||||
err := a.DNS.Shutdown(ctx)
|
err := a.DNS.Shutdown(ctx)
|
||||||
|
if bpErr := a.BlockPage.Shutdown(ctx); bpErr != nil && err == nil {
|
||||||
|
err = bpErr
|
||||||
|
}
|
||||||
if a.cancel != nil {
|
if a.cancel != nil {
|
||||||
a.cancel()
|
a.cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package app_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/owen/vibedns/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBlockPageServesMatchedPolicy exercises the whole path an operator sets
|
||||||
|
// up: a blacklist, a policy that sinkholes to this host, and a network that
|
||||||
|
// covers the test client. Hitting the block page listener for the blocked
|
||||||
|
// hostname should re-evaluate the same policy the DNS engine would have used,
|
||||||
|
// and the rendered page should carry the matched list and policy names.
|
||||||
|
func TestBlockPageServesMatchedPolicy(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
list, err := a.CreateDomainList(ctx, testActor(), app.ListInput{
|
||||||
|
Kind: "blacklist", Name: "Test Ads",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create list: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := a.AddDomain(ctx, testActor(), list.ID, "ads.example.com", true, ""); err != nil {
|
||||||
|
t.Fatalf("add domain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
policy, err := a.CreatePolicy(ctx, testActor(), app.PolicyInput{
|
||||||
|
Name: "Block Test",
|
||||||
|
BlockAction: "sinkhole",
|
||||||
|
SinkholeIPv4: "127.0.0.1",
|
||||||
|
SinkholeIPv6: "::1",
|
||||||
|
ListIDs: []int64{list.ID},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create policy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := a.CreateNetwork(ctx, testActor(), app.NetworkInput{
|
||||||
|
Name: "Test Client", CIDR: "127.0.0.1/32", PolicyIDs: []int64{policy.ID},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create network: %v", err)
|
||||||
|
}
|
||||||
|
if err := a.Reload(ctx); err != nil {
|
||||||
|
t.Fatalf("reload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpAddr = "127.0.0.1:18280"
|
||||||
|
const httpsAddr = "127.0.0.1:18243"
|
||||||
|
if err := a.BlockPage.Start(httpAddr, httpsAddr); err != nil {
|
||||||
|
t.Fatalf("start block page server: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = a.BlockPage.Shutdown(ctx)
|
||||||
|
})
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the listener to actually accept, rather than sleeping blindly.
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
if a.BlockPage.Running() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("block page server never reported running")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, "http://"+httpAddr+"/tracker.js", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build request: %v", err)
|
||||||
|
}
|
||||||
|
req.Host = "ads.example.com"
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET block page: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read body: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
page := string(body)
|
||||||
|
if !strings.Contains(page, "Test Ads") {
|
||||||
|
t.Errorf("page does not mention the matched list name: %s", page)
|
||||||
|
}
|
||||||
|
if !strings.Contains(page, "Block Test") {
|
||||||
|
t.Errorf("page does not mention the matched policy name: %s", page)
|
||||||
|
}
|
||||||
|
if !strings.Contains(page, "ads.example.com") {
|
||||||
|
t.Errorf("page does not mention the requested domain: %s", page)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpsResp, err := client.Get("https://" + httpsAddr + "/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET https block page: %v", err)
|
||||||
|
}
|
||||||
|
defer httpsResp.Body.Close()
|
||||||
|
if httpsResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("https status = %d, want 200", httpsResp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ var RestartRequired = map[string]string{
|
|||||||
config.KeyDNSUDPListen: "DNS UDP listen address",
|
config.KeyDNSUDPListen: "DNS UDP listen address",
|
||||||
config.KeyDNSTCPListen: "DNS TCP listen address",
|
config.KeyDNSTCPListen: "DNS TCP listen address",
|
||||||
config.KeyHTTPListen: "Management HTTP listen address",
|
config.KeyHTTPListen: "Management HTTP listen address",
|
||||||
|
config.KeyBlockPageEnabled: "Block page enabled state",
|
||||||
|
config.KeyBlockPageHTTPListen: "Block page HTTP listen address",
|
||||||
|
config.KeyBlockPageHTTPSListen: "Block page HTTPS listen address",
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingsGroup names a page of the settings interface.
|
// SettingsGroup names a page of the settings interface.
|
||||||
@@ -30,6 +33,7 @@ const (
|
|||||||
GroupHTTP SettingsGroup = "http"
|
GroupHTTP SettingsGroup = "http"
|
||||||
GroupBackup SettingsGroup = "backup"
|
GroupBackup SettingsGroup = "backup"
|
||||||
GroupRateLimit SettingsGroup = "ratelimit"
|
GroupRateLimit SettingsGroup = "ratelimit"
|
||||||
|
GroupBlockPage SettingsGroup = "blockpage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SaveSettings validates and persists a complete settings object.
|
// SaveSettings validates and persists a complete settings object.
|
||||||
@@ -96,6 +100,16 @@ func (a *App) PendingRestart(ctx context.Context) []string {
|
|||||||
pending = append(pending, fmt.Sprintf("DNS TCP address (listening on %s, configured as %s)",
|
pending = append(pending, fmt.Sprintf("DNS TCP address (listening on %s, configured as %s)",
|
||||||
tcp, current.DNS.TCPListen))
|
tcp, current.DNS.TCPListen))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bpHTTP, bpHTTPS := a.BlockPage.ListenAddrs()
|
||||||
|
bpRunning := a.BlockPage.Running()
|
||||||
|
if current.BlockPage.Enabled != bpRunning {
|
||||||
|
pending = append(pending, "Block page enabled state has changed")
|
||||||
|
} else if bpRunning && (current.BlockPage.HTTPListen != bpHTTP || current.BlockPage.HTTPSListen != bpHTTPS) {
|
||||||
|
pending = append(pending, fmt.Sprintf(
|
||||||
|
"Block page address (listening on %s/%s, configured as %s/%s)",
|
||||||
|
bpHTTP, bpHTTPS, current.BlockPage.HTTPListen, current.BlockPage.HTTPSListen))
|
||||||
|
}
|
||||||
return pending
|
return pending
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
// DefaultBlockPageHTML is the block page shown for a blocked query answered
|
||||||
|
// with the sinkhole action, before an operator customises it. It is a
|
||||||
|
// self-contained document — no external stylesheets, fonts or scripts — since
|
||||||
|
// it must render for a client that is, by definition, cut off from the rest
|
||||||
|
// of the network. The placeholders are filled in per request; see the
|
||||||
|
// "Available placeholders" reference on the block page settings screen.
|
||||||
|
const DefaultBlockPageHTML = `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Blocked — {{.Domain}}</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background: #0f1115; color: #e6e8eb; padding: 24px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
max-width: 560px; width: 100%; background: #171a21; border: 1px solid #2a2e37;
|
||||||
|
border-radius: 12px; padding: 32px 36px; box-shadow: 0 10px 40px rgba(0,0,0,.35);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
width: 56px; height: 56px; border-radius: 50%; background: #3a1d1d; color: #ff6b6b;
|
||||||
|
display: flex; align-items: center; justify-content: center; font-size: 28px; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
h1 { font-size: 22px; margin: 0 0 6px; }
|
||||||
|
.domain { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: #ff8787; word-break: break-all; }
|
||||||
|
p { color: #aab0bb; line-height: 1.5; }
|
||||||
|
dl { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; margin: 20px 0 0; font-size: 14px; }
|
||||||
|
dt { color: #7c828d; }
|
||||||
|
dd { margin: 0; color: #e6e8eb; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||||
|
.footer { margin-top: 24px; font-size: 12px; color: #565c68; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon">⚠</div>
|
||||||
|
<h1>This domain is blocked</h1>
|
||||||
|
<p><span class="domain">{{.Domain}}</span> was blocked by the network's DNS filtering policy.</p>
|
||||||
|
<dl>
|
||||||
|
<dt>Blocklist</dt><dd>{{.ListName}}</dd>
|
||||||
|
<dt>Matched rule</dt><dd>{{.MatchedDomain}}</dd>
|
||||||
|
<dt>Policy</dt><dd>{{.PolicyName}}</dd>
|
||||||
|
<dt>Network</dt><dd>{{.NetworkName}}</dd>
|
||||||
|
<dt>Your address</dt><dd>{{.ClientIP}}</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="footer">Blocked at {{.Timestamp}} by VibeDNS</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
@@ -71,6 +71,11 @@ const (
|
|||||||
KeyLogLevel = "log.level"
|
KeyLogLevel = "log.level"
|
||||||
KeyLogFormat = "log.format"
|
KeyLogFormat = "log.format"
|
||||||
KeyAuditMaxRows = "log.audit_max_rows"
|
KeyAuditMaxRows = "log.audit_max_rows"
|
||||||
|
|
||||||
|
KeyBlockPageEnabled = "blockpage.enabled"
|
||||||
|
KeyBlockPageHTTPListen = "blockpage.http_listen"
|
||||||
|
KeyBlockPageHTTPSListen = "blockpage.https_listen"
|
||||||
|
KeyBlockPageHTML = "blockpage.html"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DNSSettings covers the listeners and protocol behaviour.
|
// DNSSettings covers the listeners and protocol behaviour.
|
||||||
@@ -165,6 +170,15 @@ type LoggingSettings struct {
|
|||||||
AuditMaxRows int `json:"audit_max_rows"`
|
AuditMaxRows int `json:"audit_max_rows"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BlockPageSettings covers the HTTP/HTTPS server that answers sinkholed
|
||||||
|
// traffic with a page explaining why the request was blocked.
|
||||||
|
type BlockPageSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
HTTPListen string `json:"http_listen"`
|
||||||
|
HTTPSListen string `json:"https_listen"`
|
||||||
|
HTML string `json:"html"`
|
||||||
|
}
|
||||||
|
|
||||||
// Settings is the complete runtime configuration held in SQLite.
|
// Settings is the complete runtime configuration held in SQLite.
|
||||||
type Settings struct {
|
type Settings struct {
|
||||||
DNS DNSSettings `json:"dns"`
|
DNS DNSSettings `json:"dns"`
|
||||||
@@ -175,6 +189,7 @@ type Settings struct {
|
|||||||
HTTP HTTPSettings `json:"http"`
|
HTTP HTTPSettings `json:"http"`
|
||||||
Backup BackupSettings `json:"backup"`
|
Backup BackupSettings `json:"backup"`
|
||||||
Logging LoggingSettings `json:"logging"`
|
Logging LoggingSettings `json:"logging"`
|
||||||
|
BlockPage BlockPageSettings `json:"block_page"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultSettings returns a safe, closed-by-default configuration.
|
// DefaultSettings returns a safe, closed-by-default configuration.
|
||||||
@@ -247,6 +262,12 @@ func DefaultSettings() Settings {
|
|||||||
Format: "text",
|
Format: "text",
|
||||||
AuditMaxRows: 50_000,
|
AuditMaxRows: 50_000,
|
||||||
},
|
},
|
||||||
|
BlockPage: BlockPageSettings{
|
||||||
|
Enabled: false,
|
||||||
|
HTTPListen: ":80",
|
||||||
|
HTTPSListen: ":443",
|
||||||
|
HTML: DefaultBlockPageHTML,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +353,11 @@ func LoadSettings(stored map[string]string) Settings {
|
|||||||
s.Logging.Format = g.str(KeyLogFormat, s.Logging.Format)
|
s.Logging.Format = g.str(KeyLogFormat, s.Logging.Format)
|
||||||
s.Logging.AuditMaxRows = g.integer(KeyAuditMaxRows, s.Logging.AuditMaxRows)
|
s.Logging.AuditMaxRows = g.integer(KeyAuditMaxRows, s.Logging.AuditMaxRows)
|
||||||
|
|
||||||
|
s.BlockPage.Enabled = g.boolean(KeyBlockPageEnabled, s.BlockPage.Enabled)
|
||||||
|
s.BlockPage.HTTPListen = g.str(KeyBlockPageHTTPListen, s.BlockPage.HTTPListen)
|
||||||
|
s.BlockPage.HTTPSListen = g.str(KeyBlockPageHTTPSListen, s.BlockPage.HTTPSListen)
|
||||||
|
s.BlockPage.HTML = g.str(KeyBlockPageHTML, s.BlockPage.HTML)
|
||||||
|
|
||||||
s.Normalise()
|
s.Normalise()
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -398,6 +424,11 @@ func (s Settings) ToMap() map[string]string {
|
|||||||
KeyLogLevel: s.Logging.Level,
|
KeyLogLevel: s.Logging.Level,
|
||||||
KeyLogFormat: s.Logging.Format,
|
KeyLogFormat: s.Logging.Format,
|
||||||
KeyAuditMaxRows: itoa(s.Logging.AuditMaxRows),
|
KeyAuditMaxRows: itoa(s.Logging.AuditMaxRows),
|
||||||
|
|
||||||
|
KeyBlockPageEnabled: boolStr(s.BlockPage.Enabled),
|
||||||
|
KeyBlockPageHTTPListen: s.BlockPage.HTTPListen,
|
||||||
|
KeyBlockPageHTTPSListen: s.BlockPage.HTTPSListen,
|
||||||
|
KeyBlockPageHTML: s.BlockPage.HTML,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,6 +493,10 @@ func (s *Settings) Normalise() {
|
|||||||
s.Logging.Format = "text"
|
s.Logging.Format = "text"
|
||||||
}
|
}
|
||||||
s.Logging.AuditMaxRows = clamp(s.Logging.AuditMaxRows, 100, 10_000_000)
|
s.Logging.AuditMaxRows = clamp(s.Logging.AuditMaxRows, 100, 10_000_000)
|
||||||
|
|
||||||
|
if strings.TrimSpace(s.BlockPage.HTML) == "" {
|
||||||
|
s.BlockPage.HTML = DefaultBlockPageHTML
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate reports configuration errors that should be shown to the operator
|
// Validate reports configuration errors that should be shown to the operator
|
||||||
@@ -509,6 +544,22 @@ func (s Settings) Validate() error {
|
|||||||
if s.Backup.Enabled && strings.TrimSpace(s.Backup.Directory) == "" {
|
if s.Backup.Enabled && strings.TrimSpace(s.Backup.Directory) == "" {
|
||||||
return fmt.Errorf("backups are enabled but no backup directory is set")
|
return fmt.Errorf("backups are enabled but no backup directory is set")
|
||||||
}
|
}
|
||||||
|
if s.BlockPage.Enabled {
|
||||||
|
if err := validateListenAddr(s.BlockPage.HTTPListen); err != nil {
|
||||||
|
return fmt.Errorf("block page HTTP listen address: %w", err)
|
||||||
|
}
|
||||||
|
if err := validateListenAddr(s.BlockPage.HTTPSListen); err != nil {
|
||||||
|
return fmt.Errorf("block page HTTPS listen address: %w", err)
|
||||||
|
}
|
||||||
|
if s.BlockPage.HTTPListen == s.BlockPage.HTTPSListen {
|
||||||
|
return fmt.Errorf("block page HTTP and HTTPS listen addresses must differ")
|
||||||
|
}
|
||||||
|
for _, other := range []string{s.HTTP.Listen, s.DNS.UDPListen, s.DNS.TCPListen} {
|
||||||
|
if s.BlockPage.HTTPListen == other || s.BlockPage.HTTPSListen == other {
|
||||||
|
return fmt.Errorf("block page listen address %q collides with another listener", other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}/delete", page(s.handleBackupDelete))
|
||||||
mux.Handle("POST /settings/database/backup/{name}/restore", page(s.handleBackupRestore))
|
mux.Handle("POST /settings/database/backup/{name}/restore", page(s.handleBackupRestore))
|
||||||
mux.Handle("POST /settings/database/restore/cancel", page(s.handleRestoreCancel))
|
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("GET /settings/api", page(s.handleSettingsAPI))
|
||||||
mux.Handle("POST /settings/api/tokens", page(s.handleTokenCreate))
|
mux.Handle("POST /settings/api/tokens", page(s.handleTokenCreate))
|
||||||
mux.Handle("POST /settings/api/tokens/{id}/delete", page(s.handleTokenDelete))
|
mux.Handle("POST /settings/api/tokens/{id}/delete", page(s.handleTokenDelete))
|
||||||
|
|||||||
@@ -254,6 +254,37 @@
|
|||||||
if (input.value) { update(); }
|
if (input.value) { update(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Block page live preview -------------------------------------------
|
||||||
|
|
||||||
|
/** Renders the (unsaved) block page HTML in an iframe as the operator types. */
|
||||||
|
function initBlockPagePreview() {
|
||||||
|
var textarea = document.getElementById('blockPageHTML');
|
||||||
|
var frame = document.getElementById('blockPagePreview');
|
||||||
|
if (!textarea || !frame) { return; }
|
||||||
|
var form = textarea.form;
|
||||||
|
|
||||||
|
var timer = null;
|
||||||
|
function update() {
|
||||||
|
var body = new URLSearchParams();
|
||||||
|
body.set('html', textarea.value);
|
||||||
|
var csrf = form.querySelector('[name="_csrf"]');
|
||||||
|
if (csrf) { body.set('_csrf', csrf.value); }
|
||||||
|
fetch(form.getAttribute('data-preview-url'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString()
|
||||||
|
}).then(function (r) { return r.text(); }).then(function (html) {
|
||||||
|
frame.srcdoc = html;
|
||||||
|
}).catch(function () { /* leave the last good preview showing */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.addEventListener('input', function () {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(update, 400);
|
||||||
|
});
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Page data --------------------------------------------------------
|
// --- Page data --------------------------------------------------------
|
||||||
|
|
||||||
/* Page data is delivered in data- attributes rather than inline <script>
|
/* Page data is delivered in data- attributes rather than inline <script>
|
||||||
@@ -533,6 +564,7 @@
|
|||||||
initBulkSelect();
|
initBulkSelect();
|
||||||
initCopyButtons();
|
initCopyButtons();
|
||||||
initReversePreview();
|
initReversePreview();
|
||||||
|
initBlockPagePreview();
|
||||||
initRecordEditor();
|
initRecordEditor();
|
||||||
initDelegatedActions();
|
initDelegatedActions();
|
||||||
initCharts();
|
initCharts();
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{$s := .Data.S}}
|
||||||
|
|
||||||
|
<h1 class="page-title mb-1">Settings</h1>
|
||||||
|
<p class="text-body-secondary mb-4">Server configuration, stored in the database.</p>
|
||||||
|
|
||||||
|
{{template "settingsnav" .}}
|
||||||
|
|
||||||
|
<form method="post" action="/settings/blockpage" data-preview-url="/settings/blockpage/preview">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-xl-4">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">Block page server</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input type="hidden" name="enabled" value="false">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="bpEnabled"
|
||||||
|
name="enabled" value="true" {{if $s.BlockPage.Enabled}}checked{{end}}>
|
||||||
|
<label class="form-check-label" for="bpEnabled">
|
||||||
|
Serve a block page on HTTP/HTTPS {{template "restartbadge"}}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text mb-3">
|
||||||
|
Point a policy's sinkhole address at this host, and a browser that
|
||||||
|
lands here instead of the real site sees the page below. Ports
|
||||||
|
below 1024 need root or
|
||||||
|
<span class="mono">CAP_NET_BIND_SERVICE</span>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="form-label" for="bpHTTP">HTTP listen address</label>
|
||||||
|
<input type="text" class="form-control mono mb-3" id="bpHTTP" name="http_listen"
|
||||||
|
value="{{$s.BlockPage.HTTPListen}}" placeholder=":80" required>
|
||||||
|
|
||||||
|
<label class="form-label" for="bpHTTPS">HTTPS listen address</label>
|
||||||
|
<input type="text" class="form-control mono mb-3" id="bpHTTPS" name="https_listen"
|
||||||
|
value="{{$s.BlockPage.HTTPSListen}}" placeholder=":443" required>
|
||||||
|
<div class="form-text">
|
||||||
|
HTTPS is served with a self-signed certificate generated per
|
||||||
|
hostname on the fly — browsers will show a trust warning, which
|
||||||
|
is expected: this is not the real site's certificate.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-3">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2">
|
||||||
|
<span class="status-dot {{if .Data.Running}}status-up{{else}}status-down{{end}}"></span>
|
||||||
|
<span class="fw-semibold">
|
||||||
|
{{if .Data.Running}}Listeners running{{else}}Listeners stopped{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<dl class="row small mb-0">
|
||||||
|
<dt class="col-5 text-body-secondary">HTTP</dt>
|
||||||
|
<dd class="col-7 mono">{{if .Data.BoundHTTP}}{{.Data.BoundHTTP}}{{else}}—{{end}}</dd>
|
||||||
|
<dt class="col-5 text-body-secondary">HTTPS</dt>
|
||||||
|
<dd class="col-7 mono">{{if .Data.BoundHTTPS}}{{.Data.BoundHTTPS}}{{else}}—{{end}}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Available placeholders</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-body-secondary small mb-2">
|
||||||
|
Filled in per request by re-evaluating the requesting client
|
||||||
|
against the policy engine, the same way a DNS query would be.
|
||||||
|
</p>
|
||||||
|
<dl class="row small mb-0">
|
||||||
|
<dt class="col-5 mono">{{"{{.Domain}}"}}</dt>
|
||||||
|
<dd class="col-7 text-body-secondary">Requested hostname</dd>
|
||||||
|
<dt class="col-5 mono">{{"{{.ListName}}"}}</dt>
|
||||||
|
<dd class="col-7 text-body-secondary">Matched blocklist name</dd>
|
||||||
|
<dt class="col-5 mono">{{"{{.MatchedDomain}}"}}</dt>
|
||||||
|
<dd class="col-7 text-body-secondary">Matched list entry</dd>
|
||||||
|
<dt class="col-5 mono">{{"{{.PolicyName}}"}}</dt>
|
||||||
|
<dd class="col-7 text-body-secondary">Matched policy name</dd>
|
||||||
|
<dt class="col-5 mono">{{"{{.NetworkName}}"}}</dt>
|
||||||
|
<dd class="col-7 text-body-secondary">Matched client network</dd>
|
||||||
|
<dt class="col-5 mono">{{"{{.ClientIP}}"}}</dt>
|
||||||
|
<dd class="col-7 text-body-secondary">Requesting address</dd>
|
||||||
|
<dt class="col-5 mono">{{"{{.RequestPath}}"}}</dt>
|
||||||
|
<dd class="col-7 text-body-secondary">URL path requested</dd>
|
||||||
|
<dt class="col-5 mono">{{"{{.Scheme}}"}}</dt>
|
||||||
|
<dd class="col-7 text-body-secondary">http or https</dd>
|
||||||
|
<dt class="col-5 mono">{{"{{.Timestamp}}"}}</dt>
|
||||||
|
<dd class="col-7 text-body-secondary">Time of the request</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-xl-8">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">HTML editor</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<textarea class="form-control mono" id="blockPageHTML" name="html" rows="22"
|
||||||
|
spellcheck="false" style="font-size:.85rem">{{$s.BlockPage.HTML}}</textarea>
|
||||||
|
<div class="form-text">
|
||||||
|
Standard Go template syntax. A broken template falls back to a
|
||||||
|
plain built-in page rather than breaking the listener.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Preview</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<iframe id="blockPagePreview" title="Block page preview"
|
||||||
|
style="width:100%;height:480px;border:0;border-radius:0 0 .5rem .5rem;background:#fff"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions mt-3">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Save block page settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
@@ -169,6 +169,7 @@
|
|||||||
<li class="nav-item"><a class="nav-link {{if eq .Subnav "resolver"}}active{{end}}" href="/settings/resolver">Resolver</a></li>
|
<li class="nav-item"><a class="nav-link {{if eq .Subnav "resolver"}}active{{end}}" href="/settings/resolver">Resolver</a></li>
|
||||||
<li class="nav-item"><a class="nav-link {{if eq .Subnav "cache"}}active{{end}}" href="/settings/cache">Cache</a></li>
|
<li class="nav-item"><a class="nav-link {{if eq .Subnav "cache"}}active{{end}}" href="/settings/cache">Cache</a></li>
|
||||||
<li class="nav-item"><a class="nav-link {{if eq .Subnav "http"}}active{{end}}" href="/settings/http">Web Server</a></li>
|
<li class="nav-item"><a class="nav-link {{if eq .Subnav "http"}}active{{end}}" href="/settings/http">Web Server</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link {{if eq .Subnav "blockpage"}}active{{end}}" href="/settings/blockpage">Block Page</a></li>
|
||||||
<li class="nav-item"><a class="nav-link {{if eq .Subnav "logging"}}active{{end}}" href="/settings/logging">Logging</a></li>
|
<li class="nav-item"><a class="nav-link {{if eq .Subnav "logging"}}active{{end}}" href="/settings/logging">Logging</a></li>
|
||||||
<li class="nav-item"><a class="nav-link {{if eq .Subnav "database"}}active{{end}}" href="/settings/database">Database</a></li>
|
<li class="nav-item"><a class="nav-link {{if eq .Subnav "database"}}active{{end}}" href="/settings/database">Database</a></li>
|
||||||
<li class="nav-item"><a class="nav-link {{if eq .Subnav "api"}}active{{end}}" href="/settings/api">API</a></li>
|
<li class="nav-item"><a class="nav-link {{if eq .Subnav "api"}}active{{end}}" href="/settings/api">API</a></li>
|
||||||
|
|||||||
Reference in New Issue
Block a user