diff --git a/internal/app/app.go b/internal/app/app.go index 845e400..ef3d6f8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -12,6 +12,7 @@ import ( "github.com/owen/vibedns/internal/auditlog" "github.com/owen/vibedns/internal/auth" "github.com/owen/vibedns/internal/backup" + "github.com/owen/vibedns/internal/blockpage" "github.com/owen/vibedns/internal/cache" "github.com/owen/vibedns/internal/config" "github.com/owen/vibedns/internal/database" @@ -30,19 +31,20 @@ const keyCSRFSecret = "security.csrf_key" // App wires every component together and exposes the operations the // management interface performs. type App struct { - Boot config.Bootstrap - DB *database.DB - Runtime *runtimecfg.Manager - Cache *cache.Cache - Resolver *resolver.Resolver - Limiter *ratelimit.Limiter - Metrics *metrics.Metrics - QueryLog *querylog.Logger - Audit *auditlog.Logger - Auth *auth.Authenticator - Backups *backup.Manager - DNS *dnsengine.Server - Log *slog.Logger + Boot config.Bootstrap + DB *database.DB + Runtime *runtimecfg.Manager + Cache *cache.Cache + Resolver *resolver.Resolver + Limiter *ratelimit.Limiter + Metrics *metrics.Metrics + QueryLog *querylog.Logger + Audit *auditlog.Logger + Auth *auth.Authenticator + Backups *backup.Manager + DNS *dnsengine.Server + BlockPage *blockpage.Server + Log *slog.Logger cancel context.CancelFunc started time.Time @@ -88,6 +90,7 @@ func New(ctx context.Context, boot config.Bootstrap, db *database.DB, log *slog. QueryLog: a.QueryLog, Log: log, }) + a.BlockPage = blockpage.New(rt, log) // Every subsystem picks up new settings from the same reload event, so a // change in the UI takes effect without a restart. @@ -203,12 +206,23 @@ func (a *App) Start(ctx context.Context) error { a.cancel() 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 } // Shutdown stops the DNS listeners and drains the background workers. func (a *App) Shutdown(ctx context.Context) error { err := a.DNS.Shutdown(ctx) + if bpErr := a.BlockPage.Shutdown(ctx); bpErr != nil && err == nil { + err = bpErr + } if a.cancel != nil { a.cancel() } diff --git a/internal/app/blockpage_test.go b/internal/app/blockpage_test.go new file mode 100644 index 0000000..5344e18 --- /dev/null +++ b/internal/app/blockpage_test.go @@ -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) + } +} diff --git a/internal/app/settings.go b/internal/app/settings.go index c0ef7ab..6ce4438 100644 --- a/internal/app/settings.go +++ b/internal/app/settings.go @@ -13,9 +13,12 @@ import ( // RestartRequired lists the settings that only take effect after a restart, // because they control a bound socket. var RestartRequired = map[string]string{ - config.KeyDNSUDPListen: "DNS UDP listen address", - config.KeyDNSTCPListen: "DNS TCP listen address", - config.KeyHTTPListen: "Management HTTP listen address", + config.KeyDNSUDPListen: "DNS UDP listen address", + config.KeyDNSTCPListen: "DNS TCP 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. @@ -30,6 +33,7 @@ const ( GroupHTTP SettingsGroup = "http" GroupBackup SettingsGroup = "backup" GroupRateLimit SettingsGroup = "ratelimit" + GroupBlockPage SettingsGroup = "blockpage" ) // 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)", 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 } diff --git a/internal/blockpage/blockpage_test.go b/internal/blockpage/blockpage_test.go new file mode 100644 index 0000000..7803f6e --- /dev/null +++ b/internal/blockpage/blockpage_test.go @@ -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(`

{{.Domain}} blocked by {{.ListName}} ({{.PolicyName}})

`) + 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(`

{{.Domain}}

`) + if err != nil { + t.Fatalf("render: %v", err) + } + if strings.Contains(out, "