From f2a9e90625f1e648622933cf406c495196279990 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 28 Sep 2026 20:08:42 +0200 Subject: [PATCH] Refuse invalid settings at start, health check follows PORT (closes #31) A set but invalid PORT (anything but plain digits from 1 to 65535) or a relative XDG_DATA_HOME now stops the start before the database opens; before, a bad PORT left the daemon running without HTTP. entrypoint.sh refuses a MALLOC_ARENA_MAX that is not a positive whole number, since glibc ignores a bad one silently. The HEALTHCHECK probes the port PORT names, 8080 when unset. PORT is now read in internal/config, so server.New takes the config. The README gives the real Linux state directory, lists XDG_DATA_HOME, and adds "Running under upaas": port, volume, environment, the 5g memory limit and the health check. Unverified: the 5g memory limit could not be exercised on the build host. Model: opus-5-5 --- Dockerfile | 4 +- README.md | 31 ++++++++++- TODO.md | 4 ++ entrypoint.sh | 6 ++ internal/config/config.go | 41 +++++++++++++- internal/config/config_test.go | 62 +++++++++++++++++++++ internal/routewatch/app_integration_test.go | 2 +- internal/server/handlers_test.go | 3 +- internal/server/server.go | 16 +++--- 9 files changed, 152 insertions(+), 17 deletions(-) create mode 100644 internal/config/config_test.go diff --git a/Dockerfile b/Dockerfile index 4112847..83d9ed0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,8 +96,8 @@ EXPOSE 8080 COPY ./entrypoint.sh /entrypoint.sh -# Health check using the health endpoint +# Health check using the health endpoint, on the port PORT names HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD curl -sf http://localhost:8080/.well-known/healthcheck.json || exit 1 + CMD curl -sf "http://localhost:${PORT:-8080}/.well-known/healthcheck.json" || exit 1 ENTRYPOINT ["/bin/bash", "/entrypoint.sh" ] diff --git a/README.md b/README.md index aaf5f9a..e1cddf5 100644 --- a/README.md +++ b/README.md @@ -166,14 +166,21 @@ Configuration is handled via environment variables and OS-specific paths: | Variable | Default | Description | |----------|----------|-------------| -| `PORT` | `8080` | HTTP server port | +| `PORT` | `8080` | HTTP server port, a whole number from 1 to 65535 | | `DEBUG` | (empty) | Set to `routewatch` for debug logging | +| `XDG_DATA_HOME` | `/var/lib` (in the Docker image) | Base of the state directory; must be an absolute path | | `GOMEMLIMIT` | `1536MiB` (in the Docker image) | Go soft memory limit; see Memory | -| `MALLOC_ARENA_MAX` | `2` (in the Docker image) | glibc malloc arena cap; see Memory | +| `MALLOC_ARENA_MAX` | `2` (in the Docker image) | glibc malloc arena cap, a positive whole number; see Memory | + +A variable that is set to an invalid value stops the start with an error and a +non-zero exit. An empty variable counts as unset. State directory (database location): - macOS: `~/Library/Application Support/routewatch/` -- Linux: `/var/lib/routewatch/` or `~/.local/share/routewatch/` +- Linux: `/var/lib/berlin.sneak.app.routewatch/` when running as root, + otherwise `$XDG_DATA_HOME/berlin.sneak.app.routewatch/` (with `XDG_DATA_HOME` + unset, `~/.local/share/berlin.sneak.app.routewatch/`). In the Docker image + this is `/var/lib/berlin.sneak.app.routewatch/`. ## Memory @@ -220,6 +227,24 @@ What happens at each limit: With `DEBUG=routewatch` the daemon logs a `System stats` line every 60 seconds with the goroutine count and Go memory figures. +## Running under upaas + +What the [upaas](https://git.eeqj.de/sneak/upaas) app needs: + +- Container port: `8080`. +- Volume: one, at container path `/var/lib/berlin.sneak.app.routewatch`. upaas + does not create the host directory, so create it before the first deploy. The + entrypoint takes ownership of it, so a root-owned directory works. +- Environment: nothing is required. Leave `XDG_DATA_HOME`, `GOMEMLIMIT` and + `MALLOC_ARENA_MAX` at the image's values. `DEBUG=routewatch` is optional and + adds the `System stats` memory line to the log. +- Memory Limit: `5g`, the 5 GiB limit from Memory above. upaas sets no swap + limit, so on a host with swap Docker allows the same amount of swap again. +- Health check: the image's `HEALTHCHECK` requests + `/.well-known/healthcheck.json` on the container port. upaas reads the + container's health 60 seconds after a deploy and fails the deploy unless it + is `healthy`. + ## Development ```bash diff --git a/TODO.md b/TODO.md index 9e25bd4..fea8ac6 100644 --- a/TODO.md +++ b/TODO.md @@ -23,6 +23,10 @@ runs make check on main. # Completed Steps +- 2026-09-28: ready to run under upaas: a set but invalid `PORT`, + `XDG_DATA_HOME` or `MALLOC_ARENA_MAX` stops the start, the health + check follows `PORT`, README "Running under upaas" section (closes + #31) - 2026-09-22: realtime in-memory database statistics: counts seeded at startup and adjusted on every write, oldest/newest route timestamps via index-end lookups; `/api/v1/stats` no longer scans the tables (closes diff --git a/entrypoint.sh b/entrypoint.sh index 82acbbe..3f0d092 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,5 +1,11 @@ #!/bin/bash +# glibc silently ignores a malformed MALLOC_ARENA_MAX, so refuse it here. +if [[ -n "${MALLOC_ARENA_MAX:-}" && ! "$MALLOC_ARENA_MAX" =~ ^[1-9][0-9]*$ ]]; then + echo "MALLOC_ARENA_MAX must be a positive whole number, got '$MALLOC_ARENA_MAX'" >&2 + exit 1 +fi + cd /var/lib/berlin.sneak.app.routewatch chown -R routewatch:routewatch . chmod 700 . diff --git a/internal/config/config.go b/internal/config/config.go index 14ae95d..87a6ce3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "runtime" + "strconv" "time" ) @@ -18,6 +19,12 @@ const ( // defaultRouteExpirationMinutes is the default route expiration timeout in minutes defaultRouteExpirationMinutes = 5 + + // defaultPort is the HTTP port used when PORT is not set + defaultPort = 8080 + + // maxPort is the highest TCP port number + maxPort = 65535 ) // Config holds configuration for the entire application @@ -25,6 +32,9 @@ type Config struct { // StateDir is the directory for all application state (database, snapshots) StateDir string + // Port is the TCP port the HTTP server listens on + Port int + // MaxRuntime is the maximum runtime (0 = run forever) MaxRuntime time.Duration @@ -43,8 +53,14 @@ func New() (*Config, error) { return nil, fmt.Errorf("failed to determine state directory: %w", err) } + port, err := getPort() + if err != nil { + return nil, err + } + return &Config{ StateDir: stateDir, + Port: port, MaxRuntime: 0, // Run forever by default EnableBatchedDatabaseWrites: true, // Enable batching by default RouteExpirationTimeout: defaultRouteExpirationMinutes * time.Minute, // For active route monitoring @@ -69,13 +85,20 @@ func getStateDirectory() (string, error) { return filepath.Join(home, "Library", "Application Support", AppIdentifier), nil case "linux", "freebsd", "openbsd", "netbsd": + // The XDG spec requires an absolute path; a relative one would put + // the database somewhere unexpected. + xdgData := os.Getenv("XDG_DATA_HOME") + if xdgData != "" && !filepath.IsAbs(xdgData) { + return "", fmt.Errorf("XDG_DATA_HOME must be an absolute path, got %q", xdgData) + } + // Unix-like: /var/lib/berlin.sneak.app.routewatch if root, else XDG_DATA_HOME if os.Geteuid() == 0 { return filepath.Join("/var/lib", AppIdentifier), nil } // Check XDG_DATA_HOME first - if xdgData := os.Getenv("XDG_DATA_HOME"); xdgData != "" { + if xdgData != "" { return filepath.Join(xdgData, AppIdentifier), nil } @@ -92,6 +115,22 @@ func getStateDirectory() (string, error) { } } +// getPort returns the HTTP port from PORT, or defaultPort when PORT is not set +func getPort() (int, error) { + value := os.Getenv("PORT") + if value == "" { + return defaultPort, nil + } + + // ParseUint, unlike Atoi, refuses a sign: the health check URL cannot use "+9090" + port, err := strconv.ParseUint(value, 10, 0) + if err != nil || port < 1 || port > maxPort { + return 0, fmt.Errorf("PORT must be a whole number from 1 to %d, got %q", maxPort, value) + } + + return int(port), nil +} + // EnsureDirectories creates all necessary directories if they don't exist func (c *Config) EnsureDirectories() error { // Ensure state directory exists diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..84f9414 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,62 @@ +package config + +import ( + "runtime" + "testing" +) + +func TestNewReadsPort(t *testing.T) { + tests := map[string]int{ + "": defaultPort, + "1": 1, + "9090": 9090, + "65535": 65535, + } + + for value, want := range tests { + t.Run(value, func(t *testing.T) { + t.Setenv("PORT", value) + t.Setenv("XDG_DATA_HOME", "") + + cfg, err := New() + if err != nil { + t.Fatalf("New() with PORT=%q: %v", value, err) + } + + if cfg.Port != want { + t.Errorf("New() with PORT=%q: Port = %d, want %d", value, cfg.Port, want) + } + }) + } +} + +func TestNewRefusesInvalidPort(t *testing.T) { + for _, value := range []string{"0", "65536", "-1", "+9090", "http", "80.5"} { + t.Run(value, func(t *testing.T) { + t.Setenv("PORT", value) + t.Setenv("XDG_DATA_HOME", "") + + if _, err := New(); err == nil { + t.Errorf("New() with PORT=%q returned no error", value) + } + }) + } +} + +func TestNewRefusesRelativeXDGDataHome(t *testing.T) { + if runtime.GOOS == "darwin" { + t.Skip("macOS does not read XDG_DATA_HOME") + } + + t.Setenv("PORT", "") + + t.Setenv("XDG_DATA_HOME", "relative/path") + if _, err := New(); err == nil { + t.Error("New() with a relative XDG_DATA_HOME returned no error") + } + + t.Setenv("XDG_DATA_HOME", "/var/lib") + if _, err := New(); err != nil { + t.Errorf("New() with XDG_DATA_HOME=/var/lib: %v", err) + } +} diff --git a/internal/routewatch/app_integration_test.go b/internal/routewatch/app_integration_test.go index 807a0bd..56efc06 100644 --- a/internal/routewatch/app_integration_test.go +++ b/internal/routewatch/app_integration_test.go @@ -450,7 +450,7 @@ func TestRouteWatchLiveFeed(t *testing.T) { } // Create server - srv := server.New(mockDB, s, logger) + srv := server.New(mockDB, s, logger, cfg) // Create RouteWatch with 5 second limit deps := Dependencies{ diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 2ac0f43..46756bd 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "git.eeqj.de/sneak/routewatch/internal/config" "git.eeqj.de/sneak/routewatch/internal/database" "git.eeqj.de/sneak/routewatch/internal/logger" "git.eeqj.de/sneak/routewatch/internal/metrics" @@ -38,7 +39,7 @@ func (d blockingStatsDB) GetStatsContext(_ context.Context) (database.Stats, err func TestStatsHandlersDoNotLeakOnTimeout(t *testing.T) { release := make(chan struct{}) db := blockingStatsDB{release: release} - s := New(db, streamer.New(logger.New(), metrics.New()), logger.New()) + s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{}) handlers := map[string]http.HandlerFunc{ "status.json": s.handleStatusJSON(), diff --git a/internal/server/server.go b/internal/server/server.go index afdd426..10f8649 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4,9 +4,10 @@ package server import ( "context" "net/http" - "os" + "strconv" "time" + "git.eeqj.de/sneak/routewatch/internal/config" "git.eeqj.de/sneak/routewatch/internal/database" "git.eeqj.de/sneak/routewatch/internal/logger" "git.eeqj.de/sneak/routewatch/internal/streamer" @@ -33,16 +34,18 @@ type Server struct { db database.Store streamer *streamer.Streamer logger *logger.Logger + port int srv *http.Server asnFetcher ASNFetcher } // New creates a new HTTP server -func New(db database.Store, streamer *streamer.Streamer, logger *logger.Logger) *Server { +func New(db database.Store, streamer *streamer.Streamer, logger *logger.Logger, cfg *config.Config) *Server { s := &Server{ db: db, streamer: streamer, logger: logger, + port: cfg.Port, } s.setupRoutes() @@ -52,11 +55,6 @@ func New(db database.Store, streamer *streamer.Streamer, logger *logger.Logger) // Start starts the HTTP server func (s *Server) Start() error { - port := os.Getenv("PORT") - if port == "" { - port = "8080" - } - const ( readHeaderTimeout = 40 * time.Second readTimeout = 60 * time.Second @@ -65,7 +63,7 @@ func (s *Server) Start() error { ) s.srv = &http.Server{ - Addr: ":" + port, + Addr: ":" + strconv.Itoa(s.port), Handler: s.router, ReadHeaderTimeout: readHeaderTimeout, ReadTimeout: readTimeout, @@ -73,7 +71,7 @@ func (s *Server) Start() error { IdleTimeout: idleTimeout, } - s.logger.Info("Starting HTTP server", "port", port, "addr", s.srv.Addr) + s.logger.Info("Starting HTTP server", "port", s.port, "addr", s.srv.Addr) // Start in goroutine but log when actually listening go func() {