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 @@