Add a read-only Settings page for the loaded configuration (closes #402)
check / check (push) Waiting to run

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
This commit is contained in:
2026-10-01 21:08:11 +00:00
parent 9d29baaa2d
commit 82478e8052
9 changed files with 348 additions and 5 deletions
+7
View File
@@ -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/
+3
View File
@@ -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"),
+14 -5
View File
@@ -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,
+130
View File
@@ -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), ", ")
}
+130
View File
@@ -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(
`<code[^>]*>([A-Z_]+)</code>\s*<code[^>]*>([^<]*)</code>`,
)
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"])
}
+16
View File
@@ -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;
+22
View File
@@ -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")
}
+2
View File
@@ -17,6 +17,7 @@
<div class="hidden md:flex items-center gap-4">
{{if .User}}
<a href="/sources" class="btn-text">Webhooks</a>
<a href="/settings" class="btn-text">Settings</a>
<a href="/user/{{.User.Username}}" class="btn-text">
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
@@ -39,6 +40,7 @@
<div class="flex flex-col gap-2">
{{if .User}}
<a href="/sources" class="btn-text w-full text-left">Webhooks</a>
<a href="/settings" class="btn-text w-full text-left">Settings</a>
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
<form method="POST" action="/pages/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
+24
View File
@@ -0,0 +1,24 @@
{{template "base" .}}
{{define "title"}}Settings - Webhooker{{end}}
{{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8">
<h1 class="text-2xl font-medium text-gray-900">Settings</h1>
<p class="text-sm text-gray-500 mt-1 mb-6">The configuration this server started with. It is set in the server's environment and cannot be changed here.</p>
<div class="card">
<div class="divide-y divide-gray-100">
{{range .Settings}}
<div class="p-4">
<div class="flex justify-between items-start gap-4">
<code class="text-sm font-medium text-gray-900">{{.Name}}</code>
<code class="text-sm text-gray-900 break-all">{{.Value}}</code>
</div>
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
</div>
{{end}}
</div>
</div>
</div>
{{end}}