From 82478e80523a94bdb54145596bf7d7e3dea5e978 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 21:08:11 +0000 Subject: [PATCH] Add a read-only Settings page for the loaded configuration (closes #402) The page at /settings, behind the login and linked from the navigation bar, lists every field of the configuration the server started with: each by its environment variable name, with the README's description and the value in effect. METRICS_PASSWORD and SENTRY_DSN show only as set or not set; their values never reach the template. The handlers now take the loaded Config from the dependency graph. Model: opus-5-5 --- README.md | 7 ++ internal/handlers/handlers.go | 3 + internal/handlers/handlers_test.go | 19 +++-- internal/handlers/settings.go | 130 +++++++++++++++++++++++++++++ internal/handlers/settings_test.go | 130 +++++++++++++++++++++++++++++ internal/server/routes.go | 16 ++++ internal/server/settings_test.go | 22 +++++ templates/navbar.html | 2 + templates/settings.html | 24 ++++++ 9 files changed, 348 insertions(+), 5 deletions(-) create mode 100644 internal/handlers/settings.go create mode 100644 internal/handlers/settings_test.go create mode 100644 internal/server/settings_test.go create mode 100644 templates/settings.html diff --git a/README.md b/README.md index 9af5688..f840b55 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,11 @@ TTY detection, and security headers are always applied. | `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted (unset: all clients behind a proxy share one rate-limit bucket; a correct login password is never throttled either way) | `""` (none) | | `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) | +The Settings page of the web UI (`/settings`, behind the login) lists +every one of these with the value the running server loaded. It is +read-only, and it shows `METRICS_PASSWORD` and `SENTRY_DSN` only as +set or not set, never their values. + #### Allowing egress to your own network By default every delivery target must resolve to a public address. The @@ -2702,6 +2707,7 @@ abuse limit later; they are tracked as future work. | ------ | ------------------------ | ----------- | | `GET` | `/user/{username}` | User profile page | | `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) | +| `GET` | `/settings` | Read-only list of the configuration the server is running with; `METRICS_PASSWORD` and `SENTRY_DSN` show only as set or not set | | `GET` | `/sources` | List user's webhooks | | `GET` | `/sources/new` | Create webhook form | | `POST` | `/sources/new` | Create webhook submission | @@ -2814,6 +2820,7 @@ webhooker/ │ │ ├── healthcheck.go # Health check handler │ │ ├── index.go # Index page handler │ │ ├── profile.go # User profile handler +│ │ ├── settings.go # Read-only Settings page handler │ │ ├── source_management.go # Webhook CRUD handlers │ │ └── webhook.go # Webhook receiver handler │ ├── healthcheck/ diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 349771e..7c3b986 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -13,6 +13,7 @@ import ( "sync/atomic" "go.uber.org/fx" + "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/globals" @@ -53,6 +54,7 @@ type HandlersParams struct { Logger *logger.Logger Globals *globals.Globals + Config *config.Config Database *database.Database WebhookDBMgr *database.WebhookDBManager Healthcheck *healthcheck.Healthcheck @@ -129,6 +131,7 @@ func New( s.templates = map[string]*template.Template{ "login.html": parsePageTemplate("login.html"), "profile.html": parsePageTemplate("profile.html"), + "settings.html": parsePageTemplate("settings.html"), "sources_list.html": parsePageTemplate("sources_list.html"), "sources_new.html": parsePageTemplate("sources_new.html"), "source_detail.html": parsePageTemplate("source_detail.html"), diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 3e9874a..026fb27 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -83,16 +83,25 @@ func newTestApp( ) *fxtest.App { t.Helper() + return newTestAppWithConfig( + t, &config.Config{DataDir: t.TempDir()}, targets..., + ) +} + +// newTestAppWithConfig is newTestApp over a caller-supplied Config. +func newTestAppWithConfig( + t *testing.T, + cfg *config.Config, + targets ...any, +) *fxtest.App { + t.Helper() + return fxtest.New( t, fx.Provide( globals.New, logger.New, - func() *config.Config { - return &config.Config{ - DataDir: t.TempDir(), - } - }, + func() *config.Config { return cfg }, database.New, database.NewWebhookDBManager, healthcheck.New, diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go new file mode 100644 index 0000000..960a60e --- /dev/null +++ b/internal/handlers/settings.go @@ -0,0 +1,130 @@ +package handlers + +import ( + "net/http" + "net/netip" + "strconv" + "strings" + + "sneak.berlin/go/webhooker/internal/config" +) + +// notSet is what the Settings page shows for a value that is empty. +const notSet = "not set" + +// settingRow is one line of the Settings page: an environment +// variable, what it controls, and the value the server loaded for it. +type settingRow struct { + Name string + Description string + Value string +} + +// HandleSettings returns a handler for the read-only Settings page, +// which lists the configuration the server started with. +func (h *Handlers) HandleSettings() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + h.renderTemplate(w, r, "settings.html", map[string]any{ + "Settings": settingRows(h.params.Config), + }) + } +} + +// settingRows lists every field of cfg under the environment variable +// it is read from, in the order of the README's configuration table. +// METRICS_PASSWORD and SENTRY_DSN are credentials, so their values +// never reach the page: only whether they are set. +func settingRows(cfg *config.Config) []settingRow { + metricsUsername := cfg.MetricsUsername + if metricsUsername == "" { + metricsUsername = notSet + } + + return []settingRow{ + {"WEBHOOKER_ENVIRONMENT", "dev or prod", cfg.Environment}, + {"PORT", "HTTP listen port", strconv.Itoa(cfg.Port)}, + { + "BIND_ADDRESS", + "IP address the HTTP listener binds", + cfg.BindAddress, + }, + { + "DATA_DIR", + "Directory for all SQLite databases", + cfg.DataDir, + }, + { + "DEBUG", + "Enable debug logging", + strconv.FormatBool(cfg.Debug), + }, + { + "MAINTENANCE_MODE", + "Report maintenanceMode: true in the healthcheck JSON. " + + "It does not change how any request is served", + strconv.FormatBool(cfg.MaintenanceMode), + }, + { + "METRICS_USERNAME", + "Basic auth username for /metrics", + metricsUsername, + }, + { + "METRICS_PASSWORD", + "Basic auth password for /metrics", + setOrNotSet(cfg.MetricsPassword), + }, + { + "SENTRY_DSN", + "Error reporting DSN. Unset leaves error reporting off", + setOrNotSet(cfg.SentryDSN), + }, + { + "RETENTION_SWEEP_INTERVAL", + "How often the retention reaper and archive sweeper run", + cfg.RetentionSweepInterval.String(), + }, + { + "SESSION_IDLE_TIMEOUT", + "Idle session timeout. Zero or negative disables idle " + + "expiry", + cfg.SessionIdleTimeout.String(), + }, + { + "RECEIVER_RATE_LIMIT", + "Receiver requests per minute per IP per entrypoint " + + "(10x that per IP across the route)", + strconv.Itoa(cfg.ReceiverRateLimit), + }, + { + "TRUSTED_PROXIES", + "CIDRs whose forwarded headers are trusted", + cidrList(cfg.TrustedProxies), + }, + { + "ALLOWED_EGRESS_CIDRS", + "CIDRs that delivery targets may reach despite the " + + "SSRF blocklist", + cidrList(cfg.AllowedEgressCIDRs), + }, + } +} + +// setOrNotSet is how the Settings page shows a credential: whether it +// has a value, never the value itself. +func setOrNotSet(value string) string { + if value == "" { + return notSet + } + + return "set" +} + +// cidrList renders a CIDR list setting for the Settings page. +func cidrList(prefixes []netip.Prefix) string { + if len(prefixes) == 0 { + return "none" + } + + return strings.Join(config.PrefixStrings(prefixes), ", ") +} diff --git a/internal/handlers/settings_test.go b/internal/handlers/settings_test.go new file mode 100644 index 0000000..9c7e3a8 --- /dev/null +++ b/internal/handlers/settings_test.go @@ -0,0 +1,130 @@ +package handlers_test + +import ( + "context" + "html" + "net/http" + "net/http/httptest" + "net/netip" + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/handlers" + "sneak.berlin/go/webhooker/internal/session" +) + +// settingsShown renders the Settings page over cfg as a logged-in user +// and returns the value it shows for each variable name, plus the +// whole page. +func settingsShown( + t *testing.T, cfg *config.Config, +) (map[string]string, string) { + t.Helper() + + var h *handlers.Handlers + + var sess *session.Session + + app := newTestAppWithConfig(t, cfg, &h, &sess) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + req := httptest.NewRequestWithContext( + context.Background(), http.MethodGet, "/settings", nil, + ) + for _, c := range authenticatedCookies(t, sess, "id", "admin") { + req.AddCookie(c) + } + + w := httptest.NewRecorder() + h.HandleSettings().ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + body := w.Body.String() + + row := regexp.MustCompile( + `]*>([A-Z_]+)\s*]*>([^<]*)`, + ) + + shown := map[string]string{} + for _, match := range row.FindAllStringSubmatch(body, -1) { + shown[match[1]] = html.UnescapeString(match[2]) + } + + return shown, body +} + +func TestSettingsPageShowsLoadedConfiguration(t *testing.T) { + t.Parallel() + + const ( + metricsPassword = "metrics-password-1f9a" + sentryKey = "dsnkey7c2e" + sentryDSN = "https://" + sentryKey + "@errors.example.com/42" + ) + + cfg := &config.Config{ + DataDir: t.TempDir(), + Debug: true, + MaintenanceMode: true, + Environment: config.EnvironmentDev, + MetricsUsername: "scraper", + MetricsPassword: metricsPassword, + Port: 9123, + SentryDSN: sentryDSN, + BindAddress: "192.0.2.10", + RetentionSweepInterval: 17 * time.Minute, + SessionIdleTimeout: 3 * time.Hour, + ReceiverRateLimit: 77, + TrustedProxies: []netip.Prefix{ + netip.MustParsePrefix("10.1.0.0/16"), + }, + AllowedEgressCIDRs: []netip.Prefix{ + netip.MustParsePrefix("192.168.5.0/24"), + netip.MustParsePrefix("fd00::/8"), + }, + } + + shown, body := settingsShown(t, cfg) + + assert.Equal(t, map[string]string{ + "WEBHOOKER_ENVIRONMENT": "dev", + "PORT": "9123", + "BIND_ADDRESS": "192.0.2.10", + "DATA_DIR": cfg.DataDir, + "DEBUG": "true", + "MAINTENANCE_MODE": "true", + "METRICS_USERNAME": "scraper", + "METRICS_PASSWORD": "set", + "SENTRY_DSN": "set", + "RETENTION_SWEEP_INTERVAL": "17m0s", + "SESSION_IDLE_TIMEOUT": "3h0m0s", + "RECEIVER_RATE_LIMIT": "77", + "TRUSTED_PROXIES": "10.1.0.0/16", + "ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8", + }, shown) + + assert.NotContains(t, body, metricsPassword) + assert.NotContains(t, body, sentryKey) + assert.Contains( + t, body, `href="/settings"`, + "the navigation bar links to the page", + ) +} + +func TestSettingsPageShowsUnsetValues(t *testing.T) { + t.Parallel() + + shown, _ := settingsShown(t, &config.Config{DataDir: t.TempDir()}) + + assert.Equal(t, "not set", shown["METRICS_USERNAME"]) + assert.Equal(t, "not set", shown["METRICS_PASSWORD"]) + assert.Equal(t, "not set", shown["SENTRY_DSN"]) + assert.Equal(t, "none", shown["TRUSTED_PROXIES"]) + assert.Equal(t, "none", shown["ALLOWED_EGRESS_CIDRS"]) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 7f3699a..fb56e0e 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -141,6 +141,7 @@ func (s *Server) setupRoutes() { s.setupPageRoutes() s.setupUserRoutes() + s.setupSettingsRoutes() s.setupSourceRoutes() s.setupWebhookRoutes() } @@ -181,6 +182,21 @@ func (s *Server) setupUserRoutes() { }) } +// setupSettingsRoutes serves the Settings page. It is GET only: +// configuration comes from the environment and nothing here changes +// it. +func (s *Server) setupSettingsRoutes() { + s.router.Route("/settings", func(r chi.Router) { + // MaxBodySize precedes CSRF and RequireAuth deliberately; + // see maxFormBodySize for why, and for what it costs. + r.Use(s.mw.MaxBodySize(maxFormBodySize)) + r.Use(s.mw.CSRF()) + r.Use(s.mw.NoCache()) + r.Use(s.mw.RequireAuth()) + r.Get("/", s.h.HandleSettings()) + }) +} + func (s *Server) setupSourceRoutes() { s.router.Route("/sources", func(r chi.Router) { // MaxBodySize precedes CSRF and RequireAuth deliberately; diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go new file mode 100644 index 0000000..e23a406 --- /dev/null +++ b/internal/server/settings_test.go @@ -0,0 +1,22 @@ +package server_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSettingsPageIsBehindLogin(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + w := env.get("/settings", nil) + assert.Equal(t, http.StatusSeeOther, w.Code) + assert.Equal(t, "/pages/login", w.Header().Get("Location")) + + w = env.get("/settings", env.authCookies(t, "id", "admin")) + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "WEBHOOKER_ENVIRONMENT") +} diff --git a/templates/navbar.html b/templates/navbar.html index edd5573..bc578a2 100644 --- a/templates/navbar.html +++ b/templates/navbar.html @@ -17,6 +17,7 @@