From 36b6e5ac41c1fd29a42e100706c64147ea99ae2f 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 | 139 +++++++++++++++++++++++++++++
internal/server/routes.go | 16 ++++
internal/server/settings_test.go | 22 +++++
templates/navbar.html | 2 +
templates/settings.html | 24 +++++
9 files changed, 357 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 8173e92..1a10d4f 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. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
| `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
@@ -2713,6 +2718,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 |
@@ -2825,6 +2831,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 ad3bcd5..687c0ae 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..3ca2bec
--- /dev/null
+++ b/internal/handlers/settings_test.go
@@ -0,0 +1,139 @@
+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"
+
+ // No two rows show the same value: DEBUG and MAINTENANCE_MODE, and
+ // METRICS_PASSWORD and SENTRY_DSN, get opposite values, so each row
+ // is checked against its own field.
+ cfg := &config.Config{
+ DataDir: t.TempDir(),
+ Debug: true,
+ MaintenanceMode: false,
+ Environment: config.EnvironmentDev,
+ MetricsUsername: "scraper",
+ MetricsPassword: metricsPassword,
+ Port: 9123,
+ 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": "false",
+ "METRICS_USERNAME": "scraper",
+ "METRICS_PASSWORD": "set",
+ "SENTRY_DSN": "not 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.Contains(
+ t, body, `href="/settings"`,
+ "the navigation bar links to the page",
+ )
+}
+
+func TestSettingsPageShowsUnsetValues(t *testing.T) {
+ t.Parallel()
+
+ const (
+ sentryKey = "dsnkey7c2e"
+ sentryDSN = "https://" + sentryKey + "@errors.example.com/42"
+ )
+
+ // SENTRY_DSN is set here and empty in the test above, the opposite
+ // of METRICS_PASSWORD, so each secret is seen both set and not set.
+ shown, body := settingsShown(t, &config.Config{
+ DataDir: t.TempDir(),
+ SentryDSN: sentryDSN,
+ })
+
+ assert.Equal(t, "not set", shown["METRICS_USERNAME"])
+ assert.Equal(t, "not set", shown["METRICS_PASSWORD"])
+ assert.Equal(t, "set", shown["SENTRY_DSN"])
+ assert.NotContains(t, body, sentryKey)
+ 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 f19482e..2a134c0 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()
}
@@ -182,6 +183,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 @@