From 2dc9a30644f7a5a2792beb22649d518d10b14f7a Mon Sep 17 00:00:00 2001 From: DcruBro Date: Sun, 6 Sep 2026 19:14:59 +0200 Subject: [PATCH] api calls --- Makefile | 27 +++- cmd/hammyd/main.go | 269 ++++++++++++++++++++++++++++++--- internal/api/errors.go | 8 +- internal/api/middleware.go | 149 ++++++++++++++++++ internal/api/router.go | 154 +++++++++++++++++++ internal/api/router_test.go | 214 ++++++++++++++++++++++++++ internal/config/config.go | 184 ++++++++++++++++++++++ internal/config/config_test.go | 103 +++++++++++++ sqlc.yaml | 19 ++- 9 files changed, 1096 insertions(+), 31 deletions(-) create mode 100644 internal/api/middleware.go create mode 100644 internal/api/router.go create mode 100644 internal/api/router_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/Makefile b/Makefile index 4396986..b29ce99 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,35 @@ -.PHONY: build run gen test tidy +BINARY := bin/hammyd +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS := -X main.version=$(VERSION) + +.PHONY: build run gen test race cover lint tidy clean build: - go build -o bin/hammyd ./cmd/hammyd + go build -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/hammyd run: - go run ./cmd/hammyd + go run -ldflags "$(LDFLAGS)" ./cmd/hammyd serve +# Regenerate internal/db from queries/*.sql. Run after touching a migration or +# a query; forgetting turns a compile error into a runtime scan error. gen: sqlc generate test: go test ./... +race: + go test -race ./... + +cover: + go test -cover ./... + +lint: + go vet ./... + gofmt -l . + tidy: - go mod tidy \ No newline at end of file + go mod tidy + +clean: + rm -rf bin \ No newline at end of file diff --git a/cmd/hammyd/main.go b/cmd/hammyd/main.go index 3e6807f..c3601ac 100644 --- a/cmd/hammyd/main.go +++ b/cmd/hammyd/main.go @@ -1,37 +1,256 @@ -// cmd/hammyd/main.go package main import ( - "context" - "log" - "os" + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" - "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" + + "git.dcrubro.com/dcrubro/hammy-backend/internal/api" + "git.dcrubro.com/dcrubro/hammy-backend/internal/config" + "git.dcrubro.com/dcrubro/hammy-backend/internal/db" + "git.dcrubro.com/dcrubro/hammy-backend/internal/ratelimit" ) +// version is stamped at build time: +// go build -ldflags "-X main.version=$(git describe --tags --always --dirty)" ./cmd/hammyd +var version = "dev" + func main() { - ctx := context.Background() + // All real work happens in run() so that deferred cleanup actually runs. + // os.Exit skips defers, so calling it anywhere but here leaks the pool and + // drops in-flight requests. + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "hammyd: %v\n", err) + os.Exit(1) + } +} - dsn := os.Getenv("HAMMY_DSN") - if dsn == "" { - log.Fatal("HAMMY_DSN is not set!") - } +func run() error { + // NotifyContext cancels on SIGINT/SIGTERM, which is what gives the server + // a chance to finish in-flight requests instead of being killed mid-write. + ctx, stop := signal.NotifyContext(context.Background(), + os.Interrupt, syscall.SIGTERM) + defer stop() - pool, err := pgxpool.New(ctx, dsn) - if err != nil { - log.Fatalf("connect: %v", err) - } - defer pool.Close() + args := os.Args[1:] - if err := pool.Ping(ctx); err != nil { - log.Fatalf("ping: %v", err) - } + cmd := "serve" + if len(args) > 0 { + cmd, args = args[0], args[1:] + } - var n int - err = pool.QueryRow(ctx, "SELECT count(*) FROM core.users").Scan(&n) - if err != nil { - log.Fatalf("query: %v", err) - } + switch cmd { + case "serve": + return runServe(ctx, args) + case "keygen": + return runKeygen(ctx, args) + case "version": + fmt.Println(version) - log.Printf("connected, %d users", n) -} \ No newline at end of file + return nil + case "help", "-h", "--help": + usage() + + return nil + default: + usage() + + return fmt.Errorf("unknown command %q", cmd) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `hammyd %s + +Usage: + hammyd serve Run the API server (default) + hammyd keygen -email … Issue an API key + hammyd version + +Configuration comes from the environment: + HAMMY_DSN Postgres connection string (required) + HAMMY_ADDR Listen address (default 127.0.0.1:8080) + HAMMY_REDIS_ADDR Redis for shared rate limiting (optional) + HAMMY_MAX_CONNS Postgres pool size (default 10) + HAMMY_LOG_LEVEL debug, info, warn, error (default info) + HAMMY_LOG_JSON true for structured logs + HAMMY_ENDUSER_HEADER Header carrying the end-user id (default X-Hammy-User) +`, version) +} + +func runServe(ctx context.Context, _ []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + + logger := newLogger(cfg) + slog.SetDefault(logger) + + logger.Info("starting", "version", version, "config", cfg.Redacted()) + + // ---- Postgres --------------------------------------------------------- + + poolCfg, err := pgxpool.ParseConfig(cfg.DSN) + if err != nil { + return fmt.Errorf("parsing DSN: %w", err) + } + + // Postgres forks a backend process per connection, so the pool size is a + // real resource on the server, not just a client-side knob. + poolCfg.MaxConns = cfg.MaxConns + poolCfg.MaxConnLifetime = time.Hour + poolCfg.MaxConnIdleTime = 15 * time.Minute + + pool, err := pgxpool.NewWithConfig(ctx, poolCfg) + if err != nil { + return fmt.Errorf("connecting to postgres: %w", err) + } + defer pool.Close() + + // Fail at startup rather than on the first request. A backend that boots + // happily and 500s on everything is much harder to diagnose. + pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + if err := pool.Ping(pingCtx); err != nil { + return fmt.Errorf("pinging postgres: %w", err) + } + + logger.Info("postgres connected", "max_conns", cfg.MaxConns) + + // ---- Redis, or not ---------------------------------------------------- + + var limiter ratelimit.Limiter + + if cfg.RedisAddr == "" { + // A single self-hosted instance does not need Redis to have working + // rate limits. Several instances do: each process would keep its own + // counters, so N processes allow N times the limit. + logger.Warn("HAMMY_REDIS_ADDR is unset, using an in-process limiter. " + + "Correct for one instance, wrong for several.") + + mem := ratelimit.NewMemory() + limiter = mem + + go sweepPeriodically(ctx, mem, time.Minute, logger) + } else { + rdb := redis.NewClient(&redis.Options{Addr: cfg.RedisAddr, DB: cfg.RedisDB}) + defer rdb.Close() + + if err := rdb.Ping(ctx).Err(); err != nil { + return fmt.Errorf("connecting to redis: %w", err) + } + + logger.Info("redis connected", "addr", cfg.RedisAddr) + + limiter = ratelimit.NewRedis(ratelimit.GoRedis{Client: rdb}) + } + + // ---- HTTP ------------------------------------------------------------- + + queries := db.New(pool) + + router := &api.Router{ + Auth: &api.Authenticator{ + Keys: api.PgKeyStore{Q: queries}, + Limiter: limiter, + Logger: logger, + EndUserHeader: cfg.EndUserHeader, + }, + Logger: logger, + Version: version, + Ready: func() error { + // Readiness checks dependencies; liveness deliberately does not. + c, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + return pool.Ping(c) + }, + } + + srv := &http.Server{ + Addr: cfg.Addr, + Handler: router.Handler(), + + // ReadHeaderTimeout is the one that matters: without it, a client can + // hold a connection open by dribbling headers forever (Slowloris). + ReadHeaderTimeout: cfg.ReadHeaderTimeout, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 2 * time.Minute, + + ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), + } + + errCh := make(chan error, 1) + + go func() { + logger.Info("listening", "addr", cfg.Addr) + + // ErrServerClosed is the normal result of Shutdown, not a failure. + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + + select { + case err := <-errCh: + return fmt.Errorf("listening: %w", err) + + case <-ctx.Done(): + logger.Info("shutdown signal received, draining") + } + + // A FRESH context: ctx is already cancelled, and passing it to Shutdown + // would abort in-flight requests immediately, which is the opposite of + // what a graceful shutdown is for. + shutdownCtx, cancelShutdown := context.WithTimeout( + context.Background(), cfg.ShutdownTimeout) + defer cancelShutdown() + + if err := srv.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("shutdown: %w", err) + } + + logger.Info("stopped cleanly") + + return nil +} + +// sweepPeriodically drops expired windows from the in-process limiter. Without +// it the map grows for every key and end user ever seen. +func sweepPeriodically(ctx context.Context, m *ratelimit.Memory, every time.Duration, logger *slog.Logger) { + t := time.NewTicker(every) + defer t.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if n := m.Sweep(); n > 0 { + logger.Debug("swept rate limit windows", "removed", n) + } + } + } +} + +func newLogger(cfg config.Config) *slog.Logger { + opts := &slog.HandlerOptions{Level: cfg.LogLevel} + + if cfg.LogJSON { + return slog.New(slog.NewJSONHandler(os.Stdout, opts)) + } + + return slog.New(slog.NewTextHandler(os.Stdout, opts)) +} diff --git a/internal/api/errors.go b/internal/api/errors.go index 0960f88..c64a2ea 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -7,7 +7,7 @@ import ( ) // Error is the single shape every failure takes. Clients get a stable machine -// readable code plus a human message. A self-hosted bot instance can branch on +// readable code plus a human message; a self-hosted bot instance can branch on // the code without parsing prose. type Error struct { Code string `json:"code"` @@ -46,6 +46,12 @@ func WriteError(w http.ResponseWriter, status int, code, message string) { } } +// writeErrorBody writes just the envelope, for callers that have already sent +// their own status code and headers. +func writeErrorBody(w http.ResponseWriter, code, message string) error { + return json.NewEncoder(w).Encode(errorEnvelope{Error{Code: code, Message: message}}) +} + // WriteJSON sends a success payload. func WriteJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..7e73d69 --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,149 @@ +package api + +import ( + "context" + "crypto/rand" + "encoding/hex" + "log/slog" + "net/http" + "runtime/debug" + "time" +) + +type requestIDKey struct{} + +// RequestIDFrom returns the id assigned by WithRequestID, or "". +func RequestIDFrom(ctx context.Context) string { + id, _ := ctx.Value(requestIDKey{}).(string) + + return id +} + +// WithRequestID tags each request so a log line can be tied to a client report. +// Honours an inbound X-Request-Id so a self-hosted bot can correlate its own +// logs with the backend's. +func WithRequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get("X-Request-Id") + + if id == "" || len(id) > 64 { + var b [8]byte + if _, err := rand.Read(b[:]); err == nil { + id = hex.EncodeToString(b[:]) + } + } + + w.Header().Set("X-Request-Id", id) + + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey{}, id))) + }) +} + +// statusRecorder captures the status code, which http.ResponseWriter does not +// expose after the fact. +type statusRecorder struct { + http.ResponseWriter + status int + bytes int +} + +func (s *statusRecorder) WriteHeader(code int) { + s.status = code + s.ResponseWriter.WriteHeader(code) +} + +func (s *statusRecorder) Write(b []byte) (int, error) { + if s.status == 0 { + s.status = http.StatusOK + } + + n, err := s.ResponseWriter.Write(b) + s.bytes += n + + return n, err +} + +// WithLogging records one line per request. +// +// Deliberately does NOT log the Authorization header, the query string or the +// body. Logs get shipped, grepped and retained far longer than anyone intends, +// and the query string is where callsigns and grid squares live. +func WithLogging(logger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &statusRecorder{ResponseWriter: w} + + next.ServeHTTP(rec, r) + + if rec.status == 0 { + rec.status = http.StatusOK + } + + attrs := []any{ + "method", r.Method, + "path", r.URL.Path, + "status", rec.status, + "bytes", rec.bytes, + "duration_ms", time.Since(start).Milliseconds(), + "request_id", RequestIDFrom(r.Context()), + } + + // Key id rather than the key, and only once authentication has + // resolved one. + if p := PrincipalFrom(r.Context()); p != nil { + attrs = append(attrs, "key_id", p.KeyID, "owner_id", p.OwnerID) + } + + level := slog.LevelInfo + if rec.status >= 500 { + level = slog.LevelError + } + + logger.Log(r.Context(), level, "request", attrs...) + }) + } +} + +// WithRecovery turns a panic in a handler into a 500 rather than a dead +// process. Go's default is to kill the whole server on an unrecovered panic in +// a handler goroutine. +func WithRecovery(logger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + rec := recover() + if rec == nil { + return + } + + // A closed connection surfaces as a panic with this value and + // is not a bug; re-panic so net/http handles it as it expects. + if rec == http.ErrAbortHandler { + panic(rec) + } + + logger.Error("panic in handler", + "err", rec, + "path", r.URL.Path, + "request_id", RequestIDFrom(r.Context()), + "stack", string(debug.Stack())) + + WriteError(w, http.StatusInternalServerError, CodeInternal, + "Something went wrong. The incident has been logged.") + }() + + next.ServeHTTP(w, r) + }) + } +} + +// Chain applies middleware so that the first argument is the outermost, which +// reads in the order requests actually traverse them. +func Chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler { + for i := len(mw) - 1; i >= 0; i-- { + h = mw[i](h) + } + + return h +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..35189af --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,154 @@ +package api + +import ( + "log/slog" + "net/http" + "strings" +) + +// Router builds the HTTP surface. Kept out of main.go so it can be exercised in +// tests with a fake KeyStore, which is where the auth wiring actually gets +// verified. +type Router struct { + Auth *Authenticator + Logger *slog.Logger + Version string + + // Ready reports whether dependencies are healthy. Nil means always ready. + Ready func() error +} + +func (rt *Router) Handler() http.Handler { + mux := http.NewServeMux() + + // Unauthenticated. Liveness must not touch the database: a health check + // that fails when Postgres blips will get the process killed by whatever + // supervises it, which turns a database wobble into an outage. + mux.Handle("GET /healthz", rt.healthz()) + + // Readiness DOES check dependencies. That is the difference. + mux.Handle("GET /readyz", rt.readyz()) + + // Everything below needs a key. + authed := func(scope string, h http.Handler) http.Handler { + return rt.Auth.Authenticate(RequireScope(scope, h)) + } + + mux.Handle("GET /v1/whoami", rt.Auth.Authenticate(Whoami())) + mux.Handle("GET /v1/reference/ping", authed(ScopeReference, rt.stub("reference"))) + + // Deliberately NO catch-all mux.Handle("/", ...) here. + // + // A "/" pattern matches every path, so it would also swallow method + // mismatches: POST /healthz would match "/" and return 404 instead of the + // 405 that Go's ServeMux produces on its own. jsonifyErrors below rewrites + // ServeMux's plain-text 404 and 405 bodies into the standard envelope, + // which keeps both correct status codes and uniform JSON errors. + return Chain(mux, + WithRequestID, + WithRecovery(rt.Logger), + WithLogging(rt.Logger), + jsonifyErrors, + ) +} + +func (rt *Router) healthz() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + WriteJSON(w, http.StatusOK, map[string]string{ + "status": "ok", + "version": rt.Version, + }) + }) +} + +func (rt *Router) readyz() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if rt.Ready != nil { + if err := rt.Ready(); err != nil { + rt.Logger.Warn("readiness check failed", "err", err) + WriteError(w, http.StatusServiceUnavailable, "not_ready", "Dependencies are unavailable.") + + return + } + } + + WriteJSON(w, http.StatusOK, map[string]string{"status": "ready"}) + }) +} + +// jsonifyErrors converts net/http's built-in plain-text 404 and 405 responses +// into the same JSON envelope every other error uses, so a client never has to +// branch on Content-Type to read an error. +func jsonifyErrors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rw := &errorRewriter{ResponseWriter: w} + + next.ServeHTTP(rw, r) + + rw.finish() + }) +} + +type errorRewriter struct { + http.ResponseWriter + intercepted bool + wroteHeader bool + status int +} + +func (e *errorRewriter) WriteHeader(code int) { + if e.wroteHeader { + return + } + + e.wroteHeader = true + e.status = code + + // Only touch responses net/http generated itself. + // + // Checking for an EMPTY Content-Type does not work: http.NotFound sets + // "text/plain; charset=utf-8" before calling WriteHeader, so by the time we + // see it the header is already populated. Test for "not already JSON" + // instead - every handler in this package writes JSON, so anything else at + // a 404 or 405 came from net/http. + if code == http.StatusNotFound || code == http.StatusMethodNotAllowed { + if !strings.HasPrefix(e.Header().Get("Content-Type"), "application/json") { + e.intercepted = true + e.Header().Set("Content-Type", "application/json; charset=utf-8") + } + } + + e.ResponseWriter.WriteHeader(code) +} + +func (e *errorRewriter) Write(b []byte) (int, error) { + if !e.wroteHeader { + e.WriteHeader(http.StatusOK) + } + + // Swallow the plain-text body; finish writes the JSON one. + if e.intercepted { + return len(b), nil + } + + return e.ResponseWriter.Write(b) +} + +func (e *errorRewriter) finish() { + if !e.intercepted { + return + } + + code, msg := "not_found", "No such endpoint." + if e.status == http.StatusMethodNotAllowed { + code, msg = "method_not_allowed", "That method is not allowed on this endpoint." + } + + _ = writeErrorBody(e.ResponseWriter, code, msg) +} + +func (rt *Router) stub(name string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + WriteJSON(w, http.StatusOK, map[string]string{"endpoint": name, "status": "not implemented yet"}) + }) +} diff --git a/internal/api/router_test.go b/internal/api/router_test.go new file mode 100644 index 0000000..10e5a02 --- /dev/null +++ b/internal/api/router_test.go @@ -0,0 +1,214 @@ +package api + +import ( + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "git.dcrubro.com/dcrubro/hammy-backend/internal/apikey" + "git.dcrubro.com/dcrubro/hammy-backend/internal/ratelimit" +) + +func testRouter(t *testing.T, ready func() error) http.Handler { + t.Helper() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + rt := &Router{ + Auth: &Authenticator{ + Keys: &fakeStore{rec: activeRecord()}, + Limiter: ratelimit.NewMemory(), + Logger: logger, + }, + Logger: logger, + Version: "test", + Ready: ready, + } + + return rt.Handler() +} + +func TestHealthzNeedsNoKey(t *testing.T) { + h := testRouter(t, nil) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + + if w.Code != http.StatusOK { + t.Fatalf("status %d, want 200", w.Code) + } + + if id := w.Header().Get("X-Request-Id"); id == "" { + t.Error("no X-Request-Id on the response") + } +} + +// Liveness must not depend on Postgres. If it did, a database blip would get +// the process killed by its supervisor and turn a wobble into an outage. +func TestHealthzIgnoresDependencies(t *testing.T) { + h := testRouter(t, func() error { return errors.New("postgres down") }) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + + if w.Code != http.StatusOK { + t.Errorf("healthz status %d with a failing dependency, want 200", w.Code) + } +} + +func TestReadyzReflectsDependencies(t *testing.T) { + h := testRouter(t, func() error { return errors.New("postgres down") }) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("readyz status %d, want 503", w.Code) + } + + ok := testRouter(t, func() error { return nil }) + w = httptest.NewRecorder() + ok.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + + if w.Code != http.StatusOK { + t.Errorf("healthy readyz status %d, want 200", w.Code) + } +} + +func TestV1RequiresKey(t *testing.T) { + h := testRouter(t, nil) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/whoami", nil)) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("status %d, want 401", w.Code) + } +} + +func TestWhoamiEndToEnd(t *testing.T) { + h := testRouter(t, nil) + + g, err := apikey.Generate(apikey.Live) + if err != nil { + t.Fatal(err) + } + + r := httptest.NewRequest(http.MethodGet, "/v1/whoami", nil) + r.Header.Set("Authorization", "Bearer "+g.Plaintext) + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + + var got struct { + KeyID int64 `json:"key_id"` + OwnerID int64 `json:"owner_id"` + Scopes []string `json:"scopes"` + } + + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + + if got.KeyID != 42 || got.OwnerID != 7 { + t.Errorf("principal not propagated: %+v", got) + } +} + +func TestScopeEnforcedOnRoute(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + rec := activeRecord() + rec.Scopes = []string{ScopeLogbook} // deliberately not reference + + rt := &Router{ + Auth: &Authenticator{ + Keys: &fakeStore{rec: rec}, + Limiter: ratelimit.NewMemory(), + Logger: logger, + }, + Logger: logger, + } + + g, _ := apikey.Generate(apikey.Live) + + r := httptest.NewRequest(http.MethodGet, "/v1/reference/ping", nil) + r.Header.Set("Authorization", "Bearer "+g.Plaintext) + + w := httptest.NewRecorder() + rt.Handler().ServeHTTP(w, r) + + if w.Code != http.StatusForbidden { + t.Errorf("status %d, want 403", w.Code) + } +} + +func TestUnknownPathIsJSON404(t *testing.T) { + h := testRouter(t, nil) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/nope", nil)) + + if w.Code != http.StatusNotFound { + t.Fatalf("status %d, want 404", w.Code) + } + + if ct := w.Header().Get("Content-Type"); ct[:16] != "application/json" { + t.Errorf("Content-Type %q, want JSON so clients can parse errors uniformly", ct) + } +} + +// A wrong method on a real path must 405, not 404. +func TestMethodMismatch(t *testing.T) { + h := testRouter(t, nil) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/healthz", nil)) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("status %d, want 405", w.Code) + } +} + +// A panicking handler must produce a 500, not kill the process. +func TestRecoveryMiddleware(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + boom := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + panic("kaboom") + }) + + h := Chain(boom, WithRequestID, WithRecovery(logger)) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil)) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("status %d, want 500", w.Code) + } + + if got := bodyCode(t, w); got != CodeInternal { + t.Errorf("code %q, want %q", got, CodeInternal) + } +} + +func TestRequestIDHonoursInbound(t *testing.T) { + h := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := RequestIDFrom(r.Context()); got != "abc123" { + t.Errorf("request id %q, want abc123", got) + } + }), WithRequestID) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("X-Request-Id", "abc123") + + h.ServeHTTP(httptest.NewRecorder(), r) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b793519 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,184 @@ +// Package config loads runtime settings from the environment. +// +// Environment variables rather than a config file, for two reasons: the DSN +// differs per machine and you are syncing the repo across two, and a file with +// credentials in it is one `git add .` away from being public. The bot uses +// config.json because its token is the only secret; the backend holds database +// credentials and key material. +package config + +import ( + "errors" + "fmt" + "log/slog" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + // Addr is the listen address. Bind to localhost in production and put a + // reverse proxy in front for TLS. + Addr string + + // DSN for Postgres. Required. + DSN string + + // MaxConns caps the pgx pool. Postgres forks a backend per connection, so + // this is a real resource, not a client-side nicety. + MaxConns int32 + + // RedisAddr enables the shared rate limiter. Empty falls back to an + // in-process limiter, which is correct for a single instance and wrong for + // several - each would keep its own counters. + RedisAddr string + RedisDB int + + // EndUserHeader carries the caller's identifier for whoever triggered the + // request, for per-user rate limiting. Empty disables it. + EndUserHeader string + + LogLevel slog.Level + LogJSON bool + + ReadHeaderTimeout time.Duration + ShutdownTimeout time.Duration +} + +func Load() (Config, error) { + c := Config{ + Addr: env("HAMMY_ADDR", "127.0.0.1:8080"), + DSN: os.Getenv("HAMMY_DSN"), + RedisAddr: os.Getenv("HAMMY_REDIS_ADDR"), + EndUserHeader: env("HAMMY_ENDUSER_HEADER", "X-Hammy-User"), + ReadHeaderTimeout: 10 * time.Second, + ShutdownTimeout: 20 * time.Second, + LogJSON: boolEnv("HAMMY_LOG_JSON", false), + } + + if c.DSN == "" { + return Config{}, errors.New("config: HAMMY_DSN is not set") + } + + n, err := intEnv("HAMMY_MAX_CONNS", 10) + if err != nil { + return Config{}, err + } + c.MaxConns = int32(n) + + if c.RedisDB, err = intEnv("HAMMY_REDIS_DB", 0); err != nil { + return Config{}, err + } + + if c.LogLevel, err = levelEnv("HAMMY_LOG_LEVEL", slog.LevelInfo); err != nil { + return Config{}, err + } + + if c.ReadHeaderTimeout, err = durEnv("HAMMY_READ_HEADER_TIMEOUT", c.ReadHeaderTimeout); err != nil { + return Config{}, err + } + + if c.ShutdownTimeout, err = durEnv("HAMMY_SHUTDOWN_TIMEOUT", c.ShutdownTimeout); err != nil { + return Config{}, err + } + + return c, nil +} + +// Redacted is safe to log at startup. The DSN carries a password. +func (c Config) Redacted() string { + redis := c.RedisAddr + if redis == "" { + redis = "(in-process limiter)" + } + + return fmt.Sprintf("addr=%s db=%s redis=%s max_conns=%d level=%s", + c.Addr, redactDSN(c.DSN), redis, c.MaxConns, c.LogLevel) +} + +// redactDSN keeps the shape of the connection string and drops the password. +func redactDSN(dsn string) string { + at := strings.LastIndex(dsn, "@") + if at < 0 { + return "(set)" + } + + scheme := strings.Index(dsn, "://") + if scheme < 0 { + return "(set)" + } + + creds := dsn[scheme+3 : at] + + user := creds + if colon := strings.Index(creds, ":"); colon >= 0 { + user = creds[:colon] + } + + return dsn[:scheme+3] + user + ":***" + dsn[at:] +} + +func env(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + + return def +} + +func intEnv(key string, def int) (int, error) { + v := os.Getenv(key) + if v == "" { + return def, nil + } + + n, err := strconv.Atoi(v) + if err != nil { + return 0, fmt.Errorf("config: %s: %w", key, err) + } + + return n, nil +} + +func boolEnv(key string, def bool) bool { + v := os.Getenv(key) + if v == "" { + return def + } + + b, err := strconv.ParseBool(v) + if err != nil { + return def + } + + return b +} + +func durEnv(key string, def time.Duration) (time.Duration, error) { + v := os.Getenv(key) + if v == "" { + return def, nil + } + + d, err := time.ParseDuration(v) + if err != nil { + return 0, fmt.Errorf("config: %s: %w", key, err) + } + + return d, nil +} + +func levelEnv(key string, def slog.Level) (slog.Level, error) { + v := os.Getenv(key) + if v == "" { + return def, nil + } + + var l slog.Level + if err := l.UnmarshalText([]byte(v)); err != nil { + return 0, fmt.Errorf("config: %s: %w", key, err) + } + + return l, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..2463d28 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,103 @@ +package config + +import ( + "log/slog" + "strings" + "testing" + "time" +) + +func TestLoadRequiresDSN(t *testing.T) { + t.Setenv("HAMMY_DSN", "") + + if _, err := Load(); err == nil { + t.Fatal("expected an error when HAMMY_DSN is unset") + } +} + +func TestLoadDefaults(t *testing.T) { + t.Setenv("HAMMY_DSN", "postgres://u:p@localhost/hammy") + + c, err := Load() + if err != nil { + t.Fatal(err) + } + + if c.Addr != "127.0.0.1:8080" { + t.Errorf("Addr %q, want localhost by default - binding 0.0.0.0 should be deliberate", c.Addr) + } + if c.MaxConns != 10 { + t.Errorf("MaxConns %d, want 10", c.MaxConns) + } + if c.LogLevel != slog.LevelInfo { + t.Errorf("LogLevel %v, want info", c.LogLevel) + } + if c.RedisAddr != "" { + t.Errorf("RedisAddr %q, want empty", c.RedisAddr) + } +} + +func TestLoadOverrides(t *testing.T) { + t.Setenv("HAMMY_DSN", "postgres://u:p@localhost/hammy") + t.Setenv("HAMMY_ADDR", ":9000") + t.Setenv("HAMMY_MAX_CONNS", "25") + t.Setenv("HAMMY_LOG_LEVEL", "debug") + t.Setenv("HAMMY_SHUTDOWN_TIMEOUT", "45s") + t.Setenv("HAMMY_REDIS_ADDR", "localhost:6379") + + c, err := Load() + if err != nil { + t.Fatal(err) + } + + if c.Addr != ":9000" || c.MaxConns != 25 || c.LogLevel != slog.LevelDebug { + t.Errorf("overrides not applied: %+v", c) + } + if c.ShutdownTimeout != 45*time.Second { + t.Errorf("ShutdownTimeout %v, want 45s", c.ShutdownTimeout) + } +} + +func TestLoadRejectsBadValues(t *testing.T) { + t.Setenv("HAMMY_DSN", "postgres://u:p@localhost/hammy") + + for _, c := range []struct{ key, val string }{ + {"HAMMY_MAX_CONNS", "lots"}, + {"HAMMY_LOG_LEVEL", "verbose"}, + {"HAMMY_SHUTDOWN_TIMEOUT", "soon"}, + } { + t.Run(c.key, func(t *testing.T) { + t.Setenv(c.key, c.val) + + if _, err := Load(); err == nil { + t.Errorf("%s=%q accepted", c.key, c.val) + } + }) + } +} + +// A DSN in a startup log is a password in a log. +func TestRedactedHidesPassword(t *testing.T) { + cases := []string{ + "postgres://hammy_app:hunter2@192.168.1.127:5432/hammy", + "postgresql://hammy_app:p%40ss@localhost/hammy?sslmode=verify-full", + } + + for _, dsn := range cases { + c := Config{DSN: dsn, Addr: ":8080", MaxConns: 10} + out := c.Redacted() + + if strings.Contains(out, "hunter2") || strings.Contains(out, "p%40ss") { + t.Errorf("password leaked: %s", out) + } + if !strings.Contains(out, "hammy_app") { + t.Errorf("username should survive for diagnosis: %s", out) + } + } + + // A DSN with no credentials must not panic or produce nonsense. + c := Config{DSN: "postgres:///hammy?host=/var/run/postgresql"} + if out := c.Redacted(); strings.Contains(out, "***") && !strings.Contains(out, "(set)") { + t.Logf("socket DSN rendered as: %s", out) + } +} diff --git a/sqlc.yaml b/sqlc.yaml index 221fde1..db729bb 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -1,16 +1,33 @@ -# sqlc.yaml version: "2" sql: - engine: "postgresql" queries: "queries" + + # Only the in-database DDL. scripts/bootstrap.sql is deliberately absent: + # sqlc parses SQL to learn the schema and cannot handle CREATE ROLE, + # ALTER DEFAULT PRIVILEGES or DO $$ blocks. schema: - "migrations/001_types.sql" - "migrations/002_core.sql" - "migrations/003_api.sql" - "migrations/004_logbook.sql" + gen: go: package: "db" out: "internal/db" sql_package: "pgx/v5" + + # Nullable columns become *T rather than sql.NullT, which is what + # internal/api/store.go expects for expires_at. emit_pointers_for_null_types: true + emit_empty_slices: true + + overrides: + # The domains are text underneath; sqlc should not invent a type. + - db_type: "core.callsign" + go_type: "string" + - db_type: "core.email" + go_type: "string" + - db_type: "core.gridsquare" + go_type: "string" \ No newline at end of file