Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3688e845b |
@@ -135,6 +135,7 @@ TTY detection, and security headers are always applied.
|
||||
| `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. See [Bind address](#bind-address) | `127.0.0.1` (image: `0.0.0.0`) |
|
||||
| `DATA_DIR` | Directory for all SQLite databases | `/var/lib/webhooker` |
|
||||
| `DEBUG` | Enable debug logging | `false` |
|
||||
| `MAINTENANCE_MODE` | Report `maintenanceMode: true` in the healthcheck JSON. It does not change how any request is served — no maintenance page exists | `false` |
|
||||
| `METRICS_USERNAME` | Basic auth username for `/metrics`. Must be set together with `METRICS_PASSWORD`; one without the other fails startup | `""` |
|
||||
| `METRICS_PASSWORD` | Basic auth password for `/metrics`. Must be set together with `METRICS_USERNAME`; one without the other fails startup | `""` |
|
||||
| `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 | `""` |
|
||||
@@ -514,8 +515,8 @@ no report is being sent — which is why it aborts rather than starting
|
||||
with reporting off. Leaving it unset is not a mistake and not affected:
|
||||
error reporting is simply off and startup is normal.
|
||||
|
||||
The boolean variable `DEBUG` accepts exactly the spellings Go's
|
||||
`strconv.ParseBool` accepts — `1`, `t`, `T`, `TRUE`,
|
||||
Boolean variables (`DEBUG`, `MAINTENANCE_MODE`) accept exactly the
|
||||
spellings Go's `strconv.ParseBool` accepts — `1`, `t`, `T`, `TRUE`,
|
||||
`true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, `False` — and nothing
|
||||
else. `yes`, `on`, and `off` are rejected rather than quietly treated
|
||||
as false.
|
||||
@@ -2774,7 +2775,7 @@ abuse limit later; they are tracked as future work.
|
||||
| Method | Path | Description |
|
||||
| ------ | --------------------------- | ----------- |
|
||||
| `GET` | `/` | Root redirect, 303 (authenticated → `/hooks`, unauthenticated → `/pages/login`) |
|
||||
| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`) |
|
||||
| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`, `maintenanceMode`) |
|
||||
| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` |
|
||||
| `POST` | `/h/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) |
|
||||
|
||||
|
||||
@@ -149,6 +149,7 @@ type ConfigParams struct {
|
||||
type Config struct {
|
||||
DataDir string
|
||||
Debug bool
|
||||
MaintenanceMode bool
|
||||
Environment string
|
||||
MetricsPassword string
|
||||
MetricsUsername string
|
||||
@@ -657,6 +658,11 @@ func loadFromEnv() (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maintenanceMode, err := envBool("MAINTENANCE_MODE", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
retentionSweepInterval, err := envPositiveDuration(
|
||||
"RETENTION_SWEEP_INTERVAL",
|
||||
defaultRetentionSweepInterval,
|
||||
@@ -706,6 +712,7 @@ func loadFromEnv() (*Config, error) {
|
||||
return &Config{
|
||||
DataDir: DataDir(),
|
||||
Debug: debug,
|
||||
MaintenanceMode: maintenanceMode,
|
||||
Environment: environment,
|
||||
MetricsUsername: metricsUsername,
|
||||
MetricsPassword: metricsPassword,
|
||||
@@ -792,6 +799,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||
// host can reach the admin UI.
|
||||
"bindAddress", s.BindAddress,
|
||||
"debug", s.Debug,
|
||||
"maintenanceMode", s.MaintenanceMode,
|
||||
"dataDir", s.DataDir,
|
||||
"retentionSweepInterval", s.RetentionSweepInterval.String(),
|
||||
// Logged because a perfectly valid non-positive value here
|
||||
|
||||
@@ -18,9 +18,10 @@ const testEnvKey = "WEBHOOKER_TEST_VALUE"
|
||||
|
||||
// Real configuration variables exercised by the config.New tests.
|
||||
const (
|
||||
envKeyPort = "PORT"
|
||||
envKeyDebug = "DEBUG"
|
||||
envKeyBindAddress = "BIND_ADDRESS"
|
||||
envKeyPort = "PORT"
|
||||
envKeyDebug = "DEBUG"
|
||||
envKeyMaintenanceMode = "MAINTENANCE_MODE"
|
||||
envKeyBindAddress = "BIND_ADDRESS"
|
||||
)
|
||||
|
||||
// Sample BIND_ADDRESS values used by the tables below.
|
||||
@@ -603,6 +604,12 @@ func flagEnvValueCases() []badEnvValueCase {
|
||||
value: "ture",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "unparseable MAINTENANCE_MODE aborts startup",
|
||||
key: envKeyMaintenanceMode,
|
||||
value: "sometimes",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,7 +656,8 @@ func TestNewUsesDefaultsWhenUnset(t *testing.T) {
|
||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||
|
||||
for _, key := range []string{
|
||||
envKeyPort, envKeyDebug, envKeyBindAddress, envKeySentryDSN,
|
||||
envKeyPort, envKeyDebug, envKeyMaintenanceMode,
|
||||
envKeyBindAddress, envKeySentryDSN,
|
||||
} {
|
||||
require.NoError(t, os.Unsetenv(key))
|
||||
}
|
||||
@@ -660,6 +668,7 @@ func TestNewUsesDefaultsWhenUnset(t *testing.T) {
|
||||
|
||||
assert.Equal(t, 8080, cfg.Port)
|
||||
assert.False(t, cfg.Debug)
|
||||
assert.False(t, cfg.MaintenanceMode)
|
||||
|
||||
// Loopback, not the wildcard: the default must not publish the
|
||||
// cleartext admin UI and the unauthenticated receiver on every
|
||||
|
||||
@@ -78,6 +78,11 @@ func (r *recordingEvictor) Evicted() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// newTestApp builds the handlers with their real dependencies. Its
|
||||
// RequireStart fails the test when starting takes longer than fx's
|
||||
// default start timeout of 15s. That limit catches a start that hangs,
|
||||
// not a busy host: measured with make test on 2026-10-02 at host load
|
||||
// 58-69 on 48 cores, the slowest of this package's starts took 0.49s.
|
||||
func newTestApp(
|
||||
t *testing.T,
|
||||
targets ...any,
|
||||
|
||||
@@ -53,6 +53,13 @@ func settingRows(cfg *config.Config) []settingRow {
|
||||
},
|
||||
{"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 — no " +
|
||||
"maintenance page exists",
|
||||
strconv.FormatBool(cfg.MaintenanceMode),
|
||||
},
|
||||
{
|
||||
"METRICS_USERNAME",
|
||||
"Basic auth username for /metrics. Must be set together " +
|
||||
|
||||
@@ -62,12 +62,14 @@ func settingsShown(
|
||||
func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Each of METRICS_USERNAME, METRICS_PASSWORD and SENTRY_DSN is the
|
||||
// only one of the three set in one of the content tests, so each
|
||||
// row is checked against its own field.
|
||||
// DEBUG and MAINTENANCE_MODE get opposite values, and each of
|
||||
// METRICS_USERNAME, METRICS_PASSWORD and SENTRY_DSN is the only one
|
||||
// of the three set in one of the content tests, 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: "",
|
||||
@@ -94,6 +96,7 @@ func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
|
||||
"BIND_ADDRESS": "192.0.2.10",
|
||||
"DATA_DIR": cfg.DataDir,
|
||||
"DEBUG": "true",
|
||||
"MAINTENANCE_MODE": "false",
|
||||
"METRICS_USERNAME": "scraper",
|
||||
"METRICS_PASSWORD": "not set",
|
||||
"SENTRY_DSN": "not set",
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
@@ -17,6 +18,7 @@ type HealthcheckParams struct {
|
||||
fx.In
|
||||
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Logger *logger.Logger
|
||||
Database *database.Database
|
||||
}
|
||||
@@ -62,6 +64,7 @@ func (s *Healthcheck) Healthcheck() *Response {
|
||||
UptimeHuman: s.uptime().String(),
|
||||
Appname: s.params.Globals.Appname,
|
||||
Version: s.params.Globals.Version,
|
||||
Maintenance: s.params.Config.MaintenanceMode,
|
||||
}
|
||||
|
||||
return resp
|
||||
@@ -75,6 +78,7 @@ type Response struct {
|
||||
UptimeHuman string `json:"uptimeHuman"`
|
||||
Version string `json:"version"`
|
||||
Appname string `json:"appname"`
|
||||
Maintenance bool `json:"maintenanceMode"`
|
||||
}
|
||||
|
||||
func (s *Healthcheck) uptime() time.Duration {
|
||||
|
||||
@@ -159,6 +159,12 @@ func (s *Server) Run() {
|
||||
s.serve()
|
||||
}
|
||||
|
||||
// MaintenanceMode returns whether the server is in maintenance
|
||||
// mode.
|
||||
func (s *Server) MaintenanceMode() bool {
|
||||
return s.params.Config.MaintenanceMode
|
||||
}
|
||||
|
||||
// enableSentry initialises the Sentry SDK when error reporting is
|
||||
// configured, and reports the failure when it is configured and cannot
|
||||
// be initialised. A DSN that is not set is not a failure: reporting
|
||||
|
||||
+9
-3
@@ -2,9 +2,10 @@
|
||||
# script/test: run the test suite.
|
||||
#
|
||||
# -timeout is applied by `go test` per package, not to the run as a whole, so
|
||||
# it only has to clear the slowest single package. That is internal/handlers,
|
||||
# measured in a cache-defeated builder stage on the 48-core shared build host
|
||||
# (2026-08-18); load- and host-dependent, not invariants:
|
||||
# it only has to clear the slowest single package. When this budget was set
|
||||
# that was internal/handlers, measured in a cache-defeated builder stage on the
|
||||
# 48-core shared build host (2026-08-18); load- and host-dependent, not
|
||||
# invariants:
|
||||
#
|
||||
# 16.9s host load 5-20, GOMAXPROCS 48
|
||||
# 45.9s / 47.3s / 49.0s three runs at deliberate host load 31-73
|
||||
@@ -23,6 +24,11 @@
|
||||
# a condition CI runs under. If a CPU-limited runner ever puts a real run near
|
||||
# 67s, that is the datum to revisit the org figure with.
|
||||
#
|
||||
# Those figures predate tests hashing the admin password at 1 MB instead of
|
||||
# 64 MB (https://git.eeqj.de/sneak/webhooker/pulls/404). After that change, in
|
||||
# a cache-defeated build at host load 44-109 (2026-10-02), internal/handlers
|
||||
# took 8.5s and the slowest package was internal/database at 15.8s.
|
||||
#
|
||||
# -p 4 -parallel 8 keep the run under 2 GB of memory: at most four test
|
||||
# binaries build or run at once, each with at most eight parallel tests. Under
|
||||
# -race every test binary and every link costs a few hundred MB, so the
|
||||
|
||||
Reference in New Issue
Block a user