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, with the description the README's configuration // table gives it (less its pointers to other README sections), in the // table's order. 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. Loopback by default, " + "so the cleartext listener is not published on every " + "interface. The Docker image ships 0.0.0.0 instead", cfg.BindAddress, }, {"DATA_DIR", "Directory for all SQLite databases", cfg.DataDir}, {"DEBUG", "Enable debug logging", strconv.FormatBool(cfg.Debug)}, { "METRICS_USERNAME", "Basic auth username for /metrics. Must be set together " + "with METRICS_PASSWORD; one without the other fails " + "startup", metricsUsername, }, { "METRICS_PASSWORD", "Basic auth password for /metrics. Must be set together " + "with METRICS_USERNAME; one without the other fails " + "startup", setOrNotSet(cfg.MetricsPassword), }, { "SENTRY_DSN", "Sentry error reporting DSN. Unset leaves error reporting " + "off; a value the Sentry SDK cannot parse fails startup " + "rather than serving with reporting silently off", setOrNotSet(cfg.SentryDSN), }, { "RETENTION_SWEEP_INTERVAL", "How often the retention reaper and archive sweeper run " + "(Go duration, must be positive). A value that does " + "not parse, or is zero or negative, fails startup", cfg.RetentionSweepInterval.String(), }, { "SESSION_IDLE_TIMEOUT", "Idle session timeout (Go duration)", cfg.SessionIdleTimeout.String(), }, { "RECEIVER_RATE_LIMIT", "Receiver requests/minute per IP per entrypoint " + "(10x that per IP across the route)", strconv.Itoa(cfg.ReceiverRateLimit), }, { "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", 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), ", ") }