feat: scaffold app skeleton with config, ULID, health endpoints, compose stack (phase 1)

- internal .env loader (real env wins) + strictly validated config struct
- ULID package: 48-bit ms timestamp + 80-bit crypto randomness, sortable
- HTTP server with recovery/request-id/trusted-proxy/access-log middleware
- /healthz, /readyz (pluggable checkers), /metricsz (counter registry)
- multi-stage Dockerfile (alpine, non-root) + compose with healthchecks,
  app bound to 127.0.0.1:8787, mongo unpublished, graceful 15s shutdown

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-12 18:10:30 +02:00
commit 976f5238f8
26 changed files with 2478 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
package httpx
import (
"context"
"net/http"
"runtime"
"time"
)
// ReadinessCheck is a named dependency probe run by /readyz. Required checks
// (Mongo) gate readiness; optional ones (atomizer, work performer) are
// reported but non-fatal, per §6.
type ReadinessCheck struct {
Name string
Required bool
Probe func(ctx context.Context) error
}
// AddReadinessCheck registers a probe; later phases plug in Mongo and the
// external services here.
func (s *Server) AddReadinessCheck(c ReadinessCheck) {
s.checks = append(s.checks, c)
}
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
type checkResult struct {
OK bool `json:"ok"`
Required bool `json:"required"`
Error string `json:"error,omitempty"`
}
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
status := "ok"
httpStatus := http.StatusOK
results := make(map[string]checkResult, len(s.checks))
for _, c := range s.checks {
res := checkResult{OK: true, Required: c.Required}
if err := c.Probe(ctx); err != nil {
res.OK = false
res.Error = err.Error()
if c.Required {
status = "unavailable"
httpStatus = http.StatusServiceUnavailable
} else if status == "ok" {
status = "degraded"
}
}
results[c.Name] = res
}
writeJSON(w, httpStatus, map[string]any{"status": status, "checks": results})
}
func (s *Server) handleMetricsz(w http.ResponseWriter, _ *http.Request) {
counters, gauges := s.metrics.Snapshot()
writeJSON(w, http.StatusOK, map[string]any{
"uptimeSec": int64(time.Since(s.startedAt).Seconds()),
"goroutines": runtime.NumGoroutine(),
"counters": counters,
"gauges": gauges,
})
}
+122
View File
@@ -0,0 +1,122 @@
package httpx
import (
"context"
"fmt"
"net/http"
"net/netip"
"runtime/debug"
"time"
"bountyboard/internal/ulid"
)
type ctxKey int
const (
ctxKeyRequestID ctxKey = iota
ctxKeyClient
)
// RequestID returns the id assigned to this request by the middleware chain.
func RequestID(ctx context.Context) string {
id, _ := ctx.Value(ctxKeyRequestID).(string)
return id
}
// Client returns the trusted-proxy-resolved client info for this request.
func Client(ctx context.Context) clientInfo {
c, _ := ctx.Value(ctxKeyClient).(clientInfo)
return c
}
// ClientIP is a convenience accessor used by rate limiting and audit logging.
func ClientIP(ctx context.Context) netip.Addr { return Client(ctx).IP }
// statusRecorder captures the response status for logging and metrics while
// staying compatible with http.ResponseController (Unwrap).
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
if r.status == 0 {
r.status = code
}
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
if r.status == 0 {
r.status = http.StatusOK
}
return r.ResponseWriter.Write(b)
}
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
func (s *Server) withMiddleware(next http.Handler) http.Handler {
return s.recoverPanic(s.assignRequestID(s.resolveClientInfo(s.accessLog(next))))
}
// recoverPanic guarantees no panic escapes a request path: it logs the stack
// and returns the standard JSON error envelope.
func (s *Server) recoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
if rec == http.ErrAbortHandler { // client went away mid-write
panic(rec)
}
s.log.Error("panic in request handler",
"err", fmt.Sprint(rec),
"requestId", RequestID(r.Context()),
"path", r.URL.Path,
"stack", string(debug.Stack()))
s.metrics.Inc("http_panics_total", 1)
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
func (s *Server) assignRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := ulid.New()
w.Header().Set("X-Request-Id", id)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyRequestID, id)))
})
}
func (s *Server) resolveClientInfo(next http.Handler) http.Handler {
defaultProto := "http"
if r := s.cfg.AppBaseURL; len(r) >= 8 && r[:8] == "https://" {
defaultProto = "https"
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
info := resolveClient(r, s.cfg.TrustedProxyCIDRs, defaultProto)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyClient, info)))
})
}
func (s *Server) accessLog(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
}
s.metrics.Inc("http_requests_total", 1)
s.metrics.Inc(fmt.Sprintf("http_responses_%dxx_total", rec.status/100), 1)
s.log.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"durationMs", time.Since(start).Milliseconds(),
"clientIp", Client(r.Context()).IP.String(),
"requestId", RequestID(r.Context()))
})
}
+84
View File
@@ -0,0 +1,84 @@
package httpx
import (
"net/http"
"net/netip"
"strings"
)
// clientInfo is the resolved view of the requester after applying the
// trusted-proxy policy. It feeds rate limiting, sessions, and the audit log.
type clientInfo struct {
IP netip.Addr
Proto string // effective scheme: "http" or "https"
Host string // effective host header
}
func parsePeer(remoteAddr string) netip.Addr {
if ap, err := netip.ParseAddrPort(remoteAddr); err == nil {
return ap.Addr().Unmap()
}
if a, err := netip.ParseAddr(remoteAddr); err == nil {
return a.Unmap()
}
return netip.Addr{}
}
func isTrusted(a netip.Addr, trusted []netip.Prefix) bool {
if !a.IsValid() {
return false
}
for _, p := range trusted {
if p.Contains(a) {
return true
}
}
return false
}
// resolveClient applies §12: X-Forwarded-For / X-Forwarded-Proto /
// X-Forwarded-Host are honored only when the direct peer is inside
// TRUSTED_PROXY_CIDRS; otherwise the headers are ignored entirely.
//
// The client IP is found by walking the X-Forwarded-For chain from the
// rightmost entry (appended by our own proxy) towards the left, skipping
// addresses that are themselves trusted proxies; the first untrusted address
// is the real client. A malformed entry stops the walk (everything further
// left is attacker-controllable).
func resolveClient(r *http.Request, trusted []netip.Prefix, defaultProto string) clientInfo {
peer := parsePeer(r.RemoteAddr)
info := clientInfo{IP: peer, Proto: defaultProto, Host: r.Host}
if len(trusted) == 0 || !isTrusted(peer, trusted) {
return info
}
var chain []string
for _, h := range r.Header.Values("X-Forwarded-For") {
for part := range strings.SplitSeq(h, ",") {
if part = strings.TrimSpace(part); part != "" {
chain = append(chain, part)
}
}
}
for i := len(chain) - 1; i >= 0; i-- {
a := parsePeer(chain[i])
if !a.IsValid() {
break
}
info.IP = a
if !isTrusted(a, trusted) {
break
}
// a is itself a trusted proxy: keep walking left. If the whole chain
// is trusted, the leftmost entry stands as the best available answer.
}
switch p := strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))); p {
case "http", "https":
info.Proto = p
}
if h := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); h != "" {
info.Host = h
}
return info
}
+154
View File
@@ -0,0 +1,154 @@
package httpx
import (
"net/http"
"net/http/httptest"
"net/netip"
"testing"
)
func prefixes(t *testing.T, cidrs ...string) []netip.Prefix {
t.Helper()
var out []netip.Prefix
for _, c := range cidrs {
p, err := netip.ParsePrefix(c)
if err != nil {
t.Fatal(err)
}
out = append(out, p)
}
return out
}
func TestResolveClient(t *testing.T) {
trusted := "172.16.0.0/12,127.0.0.0/8"
tests := []struct {
name string
remoteAddr string
cidrs string
hdr map[string][]string
wantIP string
wantProto string
wantHost string // empty = request Host
}{
{
name: "no proxies configured: headers ignored",
remoteAddr: "203.0.113.7:4444",
cidrs: "",
hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1"}, "X-Forwarded-Proto": {"https"}},
wantIP: "203.0.113.7",
wantProto: "http",
},
{
name: "untrusted peer spoofing headers: ignored",
remoteAddr: "203.0.113.7:4444",
cidrs: trusted,
hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1"}, "X-Forwarded-Proto": {"https"}, "X-Forwarded-Host": {"evil.example"}},
wantIP: "203.0.113.7",
wantProto: "http",
},
{
name: "trusted peer, single hop",
remoteAddr: "172.18.0.2:1234",
cidrs: trusted,
hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1"}, "X-Forwarded-Proto": {"https"}, "X-Forwarded-Host": {"bounty.example.com"}},
wantIP: "198.51.100.1",
wantProto: "https",
wantHost: "bounty.example.com",
},
{
name: "client-spoofed XFF prefix is skipped (rightmost untrusted wins)",
remoteAddr: "172.18.0.2:1234",
cidrs: trusted,
hdr: map[string][]string{"X-Forwarded-For": {"6.6.6.6, 198.51.100.1"}},
wantIP: "198.51.100.1",
wantProto: "http",
},
{
name: "chain of trusted proxies collapses to real client",
remoteAddr: "172.18.0.2:1234",
cidrs: trusted,
hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1, 172.20.0.5, 172.20.0.9"}},
wantIP: "198.51.100.1",
wantProto: "http",
},
{
name: "all-trusted chain falls back to leftmost",
remoteAddr: "127.0.0.1:9999",
cidrs: trusted,
hdr: map[string][]string{"X-Forwarded-For": {"172.20.0.5"}},
wantIP: "172.20.0.5",
wantProto: "http",
},
{
name: "trusted peer without forwarded headers",
remoteAddr: "172.18.0.2:1234",
cidrs: trusted,
hdr: nil,
wantIP: "172.18.0.2",
wantProto: "http",
},
{
name: "malformed XFF entry stops the walk",
remoteAddr: "172.18.0.2:1234",
cidrs: trusted,
hdr: map[string][]string{"X-Forwarded-For": {"garbage, 198.51.100.1"}},
wantIP: "198.51.100.1",
wantProto: "http",
},
{
name: "multiple XFF headers are concatenated in order",
remoteAddr: "172.18.0.2:1234",
cidrs: trusted,
hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1", "172.20.0.5"}},
wantIP: "198.51.100.1",
wantProto: "http",
},
{
name: "invalid forwarded proto ignored",
remoteAddr: "172.18.0.2:1234",
cidrs: trusted,
hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1"}, "X-Forwarded-Proto": {"gopher"}},
wantIP: "198.51.100.1",
wantProto: "http",
},
{
name: "ipv6 client through trusted proxy",
remoteAddr: "172.18.0.2:1234",
cidrs: trusted,
hdr: map[string][]string{"X-Forwarded-For": {"2001:db8::1"}},
wantIP: "2001:db8::1",
wantProto: "http",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://app.local/", nil)
r.RemoteAddr = tt.remoteAddr
for k, vs := range tt.hdr {
for _, v := range vs {
r.Header.Add(k, v)
}
}
var cidrs []netip.Prefix
if tt.cidrs != "" {
cidrs = prefixes(t, "172.16.0.0/12", "127.0.0.0/8")
}
got := resolveClient(r, cidrs, "http")
if got.IP.String() != tt.wantIP {
t.Errorf("IP = %s, want %s", got.IP, tt.wantIP)
}
if got.Proto != tt.wantProto {
t.Errorf("Proto = %s, want %s", got.Proto, tt.wantProto)
}
wantHost := tt.wantHost
if wantHost == "" {
wantHost = "app.local"
}
if got.Host != wantHost {
t.Errorf("Host = %s, want %s", got.Host, wantHost)
}
})
}
}
+34
View File
@@ -0,0 +1,34 @@
// Package httpx implements the application HTTP server: routing, middleware
// (recovery, request id, trusted-proxy client resolution, access logging),
// health endpoints, and shared JSON response helpers. Named httpx to avoid
// clashing with net/http; it lives in internal/http per the repository layout.
package httpx
import (
"encoding/json"
"log/slog"
"net/http"
)
// errorEnvelope is the consistent error body for every JSON error response:
// {"error":{"code":"...","message":"..."}}.
type errorEnvelope struct {
Error errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("write json response", "err", err)
}
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorEnvelope{Error: errorBody{Code: code, Message: message}})
}
+78
View File
@@ -0,0 +1,78 @@
package httpx
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"bountyboard/internal/config"
"bountyboard/internal/metrics"
)
// shutdownBudget is the §12 graceful-shutdown drain window.
const shutdownBudget = 15 * time.Second
type Server struct {
cfg *config.Config
log *slog.Logger
metrics *metrics.Registry
checks []ReadinessCheck
startedAt time.Time
httpSrv *http.Server
}
func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry) *Server {
s := &Server{
cfg: cfg,
log: log,
metrics: reg,
startedAt: time.Now(),
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.handleHealthz)
mux.HandleFunc("GET /readyz", s.handleReadyz)
mux.HandleFunc("GET /metricsz", s.handleMetricsz)
s.httpSrv = &http.Server{
Addr: fmt.Sprintf(":%d", cfg.AppPort),
Handler: s.withMiddleware(mux),
ReadHeaderTimeout: 10 * time.Second,
ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn),
}
return s
}
// Handler exposes the full middleware-wrapped handler for tests.
func (s *Server) Handler() http.Handler { return s.httpSrv.Handler }
// Run serves until ctx is canceled (SIGTERM/SIGINT), then drains in-flight
// requests within the shutdown budget.
func (s *Server) Run(ctx context.Context) error {
errCh := make(chan error, 1)
go func() {
s.log.Info("http server listening", "addr", s.httpSrv.Addr, "baseUrl", s.cfg.AppBaseURL)
if err := s.httpSrv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
errCh <- err
return
}
errCh <- nil
}()
select {
case err := <-errCh:
return fmt.Errorf("http server: %w", err)
case <-ctx.Done():
}
s.log.Info("shutting down", "budget", shutdownBudget.String())
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownBudget)
defer cancel()
if err := s.httpSrv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown: %w", err)
}
return <-errCh
}
+172
View File
@@ -0,0 +1,172 @@
package httpx
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"bountyboard/internal/config"
"bountyboard/internal/metrics"
"bountyboard/internal/ulid"
)
func newTestServer(t *testing.T) *Server {
t.Helper()
t.Setenv("SESSION_SECRET", "0123456789abcdef0123456789abcdef")
t.Setenv("CREDENTIALS_ENC_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32)))
cfg, err := config.Load()
if err != nil {
t.Fatalf("config: %v", err)
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
return New(cfg, log, metrics.NewRegistry())
}
func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestHealthz(t *testing.T) {
s := newTestServer(t)
rec := get(t, s.Handler(), "/healthz")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var body map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["status"] != "ok" {
t.Errorf("status field = %q", body["status"])
}
if id := rec.Header().Get("X-Request-Id"); !ulid.IsValid(id) {
t.Errorf("X-Request-Id %q is not a ULID", id)
}
}
func TestReadyzAggregation(t *testing.T) {
tests := []struct {
name string
checks []ReadinessCheck
wantStatus string
wantCode int
}{
{
name: "no checks",
wantStatus: "ok",
wantCode: http.StatusOK,
},
{
name: "all passing",
checks: []ReadinessCheck{
{Name: "mongo", Required: true, Probe: func(context.Context) error { return nil }},
{Name: "atomizer", Probe: func(context.Context) error { return nil }},
},
wantStatus: "ok",
wantCode: http.StatusOK,
},
{
name: "optional failing is non-fatal",
checks: []ReadinessCheck{
{Name: "mongo", Required: true, Probe: func(context.Context) error { return nil }},
{Name: "atomizer", Probe: func(context.Context) error { return errors.New("down") }},
},
wantStatus: "degraded",
wantCode: http.StatusOK,
},
{
name: "required failing gates readiness",
checks: []ReadinessCheck{
{Name: "mongo", Required: true, Probe: func(context.Context) error { return errors.New("no reachable servers") }},
},
wantStatus: "unavailable",
wantCode: http.StatusServiceUnavailable,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestServer(t)
for _, c := range tt.checks {
s.AddReadinessCheck(c)
}
rec := get(t, s.Handler(), "/readyz")
if rec.Code != tt.wantCode {
t.Fatalf("code = %d, want %d", rec.Code, tt.wantCode)
}
var body struct {
Status string `json:"status"`
Checks map[string]checkResult `json:"checks"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Status != tt.wantStatus {
t.Errorf("status = %q, want %q", body.Status, tt.wantStatus)
}
if len(body.Checks) != len(tt.checks) {
t.Errorf("checks = %d, want %d", len(body.Checks), len(tt.checks))
}
})
}
}
func TestMetricszCountsRequests(t *testing.T) {
s := newTestServer(t)
get(t, s.Handler(), "/healthz")
get(t, s.Handler(), "/healthz")
rec := get(t, s.Handler(), "/metricsz")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var body struct {
Counters map[string]int64 `json:"counters"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Counters["http_requests_total"] != 2 {
t.Errorf("http_requests_total = %d, want 2", body.Counters["http_requests_total"])
}
}
func TestPanicRecoveryEnvelope(t *testing.T) {
s := newTestServer(t)
mux := http.NewServeMux()
mux.HandleFunc("GET /boom", func(http.ResponseWriter, *http.Request) { panic("kaboom") })
h := s.withMiddleware(mux)
rec := get(t, h, "/boom")
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d", rec.Code)
}
var env struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatal(err)
}
if env.Error.Code != "internal" {
t.Errorf("error code = %q", env.Error.Code)
}
}
func TestUnknownRouteNotFound(t *testing.T) {
s := newTestServer(t)
rec := get(t, s.Handler(), "/nope")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}