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:
@@ -0,0 +1,196 @@
|
||||
// Package config loads and validates all application configuration from the
|
||||
// environment (optionally seeded from a .env file). The app fails fast at
|
||||
// startup on invalid configuration rather than misbehaving later.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SMTP struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
func (s SMTP) Enabled() bool { return s.Host != "" }
|
||||
|
||||
type OIDC struct {
|
||||
IssuerURL string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
func (o OIDC) Enabled() bool {
|
||||
return o.IssuerURL != "" && o.ClientID != "" && o.ClientSecret != ""
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
AppBaseURL string
|
||||
TrustedProxyCIDRs []netip.Prefix
|
||||
AppPort int
|
||||
MongoURI string
|
||||
MongoDB string
|
||||
MongoUsername string
|
||||
MongoPassword string
|
||||
SessionSecret string
|
||||
CredentialsEncKey []byte // 32 bytes, decoded
|
||||
MaxUploadMB int
|
||||
|
||||
AdminEmail string
|
||||
AdminInitialPassword string
|
||||
|
||||
AtomizerBaseURL string
|
||||
AtomizerToken string
|
||||
AtomizerTimeout time.Duration
|
||||
WorkPerformerBaseURL string
|
||||
WorkPerformerToken string
|
||||
WorkPerformerHTTPTimeout time.Duration
|
||||
|
||||
SMTP SMTP
|
||||
OIDC OIDC
|
||||
|
||||
CookieSecureMode string // auto | true | false
|
||||
AtomizeMaxConcurrency int
|
||||
}
|
||||
|
||||
// CookieSecure resolves the Secure cookie flag: explicit true/false, or in
|
||||
// auto mode derived from the APP_BASE_URL scheme.
|
||||
func (c *Config) CookieSecure() bool {
|
||||
switch c.CookieSecureMode {
|
||||
case "true":
|
||||
return true
|
||||
case "false":
|
||||
return false
|
||||
default:
|
||||
return strings.HasPrefix(c.AppBaseURL, "https://")
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(key, def string) string {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// Load reads configuration from the environment and validates it. All
|
||||
// problems are reported together so a broken deployment is fixed in one pass.
|
||||
func Load() (*Config, error) {
|
||||
var errs []error
|
||||
fail := func(format string, a ...any) { errs = append(errs, fmt.Errorf(format, a...)) }
|
||||
|
||||
atoi := func(key, def string) int {
|
||||
raw := getenv(key, def)
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
fail("%s: %q is not an integer", key, raw)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
c := &Config{
|
||||
AppBaseURL: strings.TrimRight(getenv("APP_BASE_URL", "http://localhost:8787"), "/"),
|
||||
AppPort: atoi("APP_PORT", "8787"),
|
||||
MongoURI: getenv("MONGO_URI", "mongodb://localhost:27017"),
|
||||
MongoDB: getenv("MONGO_DB", "bountyboard"),
|
||||
MongoUsername: os.Getenv("MONGO_USERNAME"),
|
||||
MongoPassword: os.Getenv("MONGO_PASSWORD"),
|
||||
SessionSecret: os.Getenv("SESSION_SECRET"),
|
||||
MaxUploadMB: atoi("MAX_UPLOAD_MB", "25"),
|
||||
AdminEmail: strings.ToLower(strings.TrimSpace(getenv("ADMIN_EMAIL", ""))),
|
||||
AdminInitialPassword: os.Getenv("ADMIN_INITIAL_PASSWORD"),
|
||||
|
||||
AtomizerBaseURL: strings.TrimRight(getenv("ATOMIZER_BASE_URL", "http://atomizer-mock:8090"), "/"),
|
||||
AtomizerToken: os.Getenv("ATOMIZER_TOKEN"),
|
||||
WorkPerformerBaseURL: strings.TrimRight(getenv("WORK_PERFORMER_BASE_URL", "http://work-performer:8091"), "/"),
|
||||
WorkPerformerToken: os.Getenv("WORK_PERFORMER_TOKEN"),
|
||||
|
||||
SMTP: SMTP{
|
||||
Host: os.Getenv("SMTP_HOST"),
|
||||
Port: atoi("SMTP_PORT", "587"),
|
||||
User: os.Getenv("SMTP_USER"),
|
||||
Password: os.Getenv("SMTP_PASSWORD"),
|
||||
From: getenv("SMTP_FROM", "bountyboard@example.com"),
|
||||
},
|
||||
OIDC: OIDC{
|
||||
IssuerURL: os.Getenv("OIDC_ISSUER_URL"),
|
||||
ClientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||
},
|
||||
|
||||
CookieSecureMode: getenv("COOKIE_SECURE", "auto"),
|
||||
AtomizeMaxConcurrency: atoi("ATOMIZE_MAX_CONCURRENCY", "3"),
|
||||
}
|
||||
c.AtomizerTimeout = time.Duration(atoi("ATOMIZER_TIMEOUT_SEC", "120")) * time.Second
|
||||
c.WorkPerformerHTTPTimeout = time.Duration(atoi("WORK_PERFORMER_HTTP_TIMEOUT_SEC", "30")) * time.Second
|
||||
|
||||
if c.AppPort < 1 || c.AppPort > 65535 {
|
||||
fail("APP_PORT: %d out of range", c.AppPort)
|
||||
}
|
||||
if u, err := url.Parse(c.AppBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
|
||||
fail("APP_BASE_URL: %q is not an absolute URL", c.AppBaseURL)
|
||||
}
|
||||
for _, name := range []string{"ATOMIZER_BASE_URL", "WORK_PERFORMER_BASE_URL"} {
|
||||
v := c.AtomizerBaseURL
|
||||
if name == "WORK_PERFORMER_BASE_URL" {
|
||||
v = c.WorkPerformerBaseURL
|
||||
}
|
||||
if u, err := url.Parse(v); err != nil || u.Scheme == "" || u.Host == "" {
|
||||
fail("%s: %q is not an absolute URL", name, v)
|
||||
}
|
||||
}
|
||||
if len(c.SessionSecret) < 16 {
|
||||
fail("SESSION_SECRET: must be at least 16 characters of random data")
|
||||
}
|
||||
switch raw := os.Getenv("CREDENTIALS_ENC_KEY"); raw {
|
||||
case "":
|
||||
fail("CREDENTIALS_ENC_KEY: required; generate with `openssl rand -base64 32`")
|
||||
default:
|
||||
key, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil || len(key) != 32 {
|
||||
fail("CREDENTIALS_ENC_KEY: must be base64 of exactly 32 bytes")
|
||||
}
|
||||
c.CredentialsEncKey = key
|
||||
}
|
||||
if c.MaxUploadMB < 1 {
|
||||
fail("MAX_UPLOAD_MB: must be >= 1")
|
||||
}
|
||||
if c.AtomizeMaxConcurrency < 1 {
|
||||
fail("ATOMIZE_MAX_CONCURRENCY: must be >= 1")
|
||||
}
|
||||
switch c.CookieSecureMode {
|
||||
case "auto", "true", "false":
|
||||
default:
|
||||
fail("COOKIE_SECURE: %q must be auto, true or false", c.CookieSecureMode)
|
||||
}
|
||||
if raw := strings.TrimSpace(os.Getenv("TRUSTED_PROXY_CIDRS")); raw != "" {
|
||||
for cidr := range strings.SplitSeq(raw, ",") {
|
||||
cidr = strings.TrimSpace(cidr)
|
||||
if cidr == "" {
|
||||
continue
|
||||
}
|
||||
p, err := netip.ParsePrefix(cidr)
|
||||
if err != nil {
|
||||
fail("TRUSTED_PROXY_CIDRS: %q is not a CIDR", cidr)
|
||||
continue
|
||||
}
|
||||
c.TrustedProxyCIDRs = append(c.TrustedProxyCIDRs, p.Masked())
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return nil, fmt.Errorf("invalid configuration:\n%w", errors.Join(errs...))
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// validEnv sets the minimum required variables for a passing Load.
|
||||
func validEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("SESSION_SECRET", "0123456789abcdef0123456789abcdef")
|
||||
t.Setenv("CREDENTIALS_ENC_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32)))
|
||||
}
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
validEnv(t)
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if c.AppPort != 8787 {
|
||||
t.Errorf("AppPort = %d, want 8787", c.AppPort)
|
||||
}
|
||||
if c.MongoDB != "bountyboard" {
|
||||
t.Errorf("MongoDB = %q", c.MongoDB)
|
||||
}
|
||||
if c.AtomizerTimeout.Seconds() != 120 {
|
||||
t.Errorf("AtomizerTimeout = %v", c.AtomizerTimeout)
|
||||
}
|
||||
if c.CookieSecure() {
|
||||
t.Error("CookieSecure should be false for http base URL in auto mode")
|
||||
}
|
||||
if len(c.CredentialsEncKey) != 32 {
|
||||
t.Errorf("CredentialsEncKey len = %d", len(c.CredentialsEncKey))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadValidationErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
value string
|
||||
wantSub string
|
||||
}{
|
||||
{"bad port", "APP_PORT", "99999", "APP_PORT"},
|
||||
{"non-numeric port", "APP_PORT", "abc", "APP_PORT"},
|
||||
{"short session secret", "SESSION_SECRET", "short", "SESSION_SECRET"},
|
||||
{"bad enc key", "CREDENTIALS_ENC_KEY", "not-base64!!", "CREDENTIALS_ENC_KEY"},
|
||||
{"wrong size enc key", "CREDENTIALS_ENC_KEY", base64.StdEncoding.EncodeToString(make([]byte, 16)), "CREDENTIALS_ENC_KEY"},
|
||||
{"bad base url", "APP_BASE_URL", "not a url", "APP_BASE_URL"},
|
||||
{"bad atomizer url", "ATOMIZER_BASE_URL", "::::", "ATOMIZER_BASE_URL"},
|
||||
{"bad cidr", "TRUSTED_PROXY_CIDRS", "10.0.0.0/8,banana", "TRUSTED_PROXY_CIDRS"},
|
||||
{"bad cookie mode", "COOKIE_SECURE", "maybe", "COOKIE_SECURE"},
|
||||
{"bad upload size", "MAX_UPLOAD_MB", "0", "MAX_UPLOAD_MB"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
validEnv(t)
|
||||
t.Setenv(tt.key, tt.value)
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("want error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantSub) {
|
||||
t.Errorf("error %q does not mention %q", err, tt.wantSub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingEncKey(t *testing.T) {
|
||||
t.Setenv("SESSION_SECRET", "0123456789abcdef0123456789abcdef")
|
||||
t.Setenv("CREDENTIALS_ENC_KEY", "")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("want error for missing CREDENTIALS_ENC_KEY")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxyCIDRs(t *testing.T) {
|
||||
validEnv(t)
|
||||
t.Setenv("TRUSTED_PROXY_CIDRS", "172.16.0.0/12, 10.0.0.0/8")
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(c.TrustedProxyCIDRs) != 2 {
|
||||
t.Fatalf("got %d prefixes, want 2", len(c.TrustedProxyCIDRs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCookieSecureModes(t *testing.T) {
|
||||
tests := []struct {
|
||||
mode, baseURL string
|
||||
want bool
|
||||
}{
|
||||
{"auto", "http://localhost:8787", false},
|
||||
{"auto", "https://bounty.example.com", true},
|
||||
{"true", "http://localhost:8787", true},
|
||||
{"false", "https://bounty.example.com", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
validEnv(t)
|
||||
t.Setenv("COOKIE_SECURE", tt.mode)
|
||||
t.Setenv("APP_BASE_URL", tt.baseURL)
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got := c.CookieSecure(); got != tt.want {
|
||||
t.Errorf("mode=%s url=%s: CookieSecure()=%v, want %v", tt.mode, tt.baseURL, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCAndSMTPEnabled(t *testing.T) {
|
||||
validEnv(t)
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.OIDC.Enabled() || c.SMTP.Enabled() {
|
||||
t.Error("OIDC and SMTP must be disabled when unset")
|
||||
}
|
||||
|
||||
t.Setenv("OIDC_ISSUER_URL", "https://issuer.example.com")
|
||||
t.Setenv("OIDC_CLIENT_ID", "id")
|
||||
t.Setenv("OIDC_CLIENT_SECRET", "secret")
|
||||
t.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
c, err = Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !c.OIDC.Enabled() || !c.SMTP.Enabled() {
|
||||
t.Error("OIDC and SMTP must be enabled when set")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LoadDotEnv reads a dotenv file and sets each variable into the process
|
||||
// environment unless it is already set (real environment wins). Supported
|
||||
// syntax: KEY=VALUE, blank lines, full-line and unquoted inline comments,
|
||||
// optional `export ` prefix, single- or double-quoted values.
|
||||
func LoadDotEnv(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
for n := 1; sc.Scan(); n++ {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimPrefix(line, "export ")
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok || strings.TrimSpace(key) == "" {
|
||||
return fmt.Errorf("%s:%d: not a KEY=VALUE line", path, n)
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
val = strings.TrimSpace(val)
|
||||
switch {
|
||||
case len(val) >= 2 && (val[0] == '"' || val[0] == '\'') && val[len(val)-1] == val[0]:
|
||||
val = val[1 : len(val)-1]
|
||||
default: // unquoted: strip trailing inline comment
|
||||
if i := strings.Index(val, " #"); i >= 0 {
|
||||
val = strings.TrimSpace(val[:i])
|
||||
}
|
||||
}
|
||||
if _, exists := os.LookupEnv(key); !exists {
|
||||
os.Setenv(key, val)
|
||||
}
|
||||
}
|
||||
return sc.Err()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadDotEnv(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".env")
|
||||
content := `# full-line comment
|
||||
PLAIN=hello
|
||||
INLINE=value # trailing comment
|
||||
QUOTED="quoted # not a comment"
|
||||
SINGLE='single quoted'
|
||||
export EXPORTED=yes
|
||||
EMPTY=
|
||||
SPACED = padded value
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("PLAIN", "preexisting") // real env must win
|
||||
for _, k := range []string{"INLINE", "QUOTED", "SINGLE", "EXPORTED", "EMPTY", "SPACED"} {
|
||||
os.Unsetenv(k)
|
||||
t.Cleanup(func() { os.Unsetenv(k) })
|
||||
}
|
||||
|
||||
if err := LoadDotEnv(path); err != nil {
|
||||
t.Fatalf("LoadDotEnv: %v", err)
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"PLAIN": "preexisting",
|
||||
"INLINE": "value",
|
||||
"QUOTED": "quoted # not a comment",
|
||||
"SINGLE": "single quoted",
|
||||
"EXPORTED": "yes",
|
||||
"EMPTY": "",
|
||||
"SPACED": "padded value",
|
||||
}
|
||||
for k, v := range want {
|
||||
if got := os.Getenv(k); got != v {
|
||||
t.Errorf("%s = %q, want %q", k, got, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDotEnvMissingFileIsOK(t *testing.T) {
|
||||
if err := LoadDotEnv(filepath.Join(t.TempDir(), "absent")); err != nil {
|
||||
t.Fatalf("missing file should not error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDotEnvMalformed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(path, []byte("NOT A KV LINE\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := LoadDotEnv(path); err == nil {
|
||||
t.Fatal("want error for malformed line")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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()))
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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}})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Package metrics is a tiny in-process registry of named counters and gauges
|
||||
// exposed as JSON at /metricsz. No external metrics system is required.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
counters map[string]*atomic.Int64
|
||||
gauges map[string]*atomic.Int64
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
counters: make(map[string]*atomic.Int64),
|
||||
gauges: make(map[string]*atomic.Int64),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) cell(m map[string]*atomic.Int64, name string) *atomic.Int64 {
|
||||
r.mu.RLock()
|
||||
c, ok := m[name]
|
||||
r.mu.RUnlock()
|
||||
if ok {
|
||||
return c
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if c, ok = m[name]; !ok {
|
||||
c = new(atomic.Int64)
|
||||
m[name] = c
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Inc adds delta to a monotonic counter.
|
||||
func (r *Registry) Inc(name string, delta int64) { r.cell(r.counters, name).Add(delta) }
|
||||
|
||||
// Set assigns the current value of a gauge (e.g. queue depth).
|
||||
func (r *Registry) Set(name string, value int64) { r.cell(r.gauges, name).Store(value) }
|
||||
|
||||
// Snapshot returns a point-in-time copy of all values for /metricsz.
|
||||
func (r *Registry) Snapshot() (counters, gauges map[string]int64) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
counters = make(map[string]int64, len(r.counters))
|
||||
for k, v := range r.counters {
|
||||
counters[k] = v.Load()
|
||||
}
|
||||
gauges = make(map[string]int64, len(r.gauges))
|
||||
for k, v := range r.gauges {
|
||||
gauges[k] = v.Load()
|
||||
}
|
||||
return counters, gauges
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Package ulid generates ULIDs (https://github.com/ulid/spec): 26-character
|
||||
// Crockford-base32 strings of a 48-bit millisecond timestamp followed by
|
||||
// 80 bits of cryptographic randomness. Lexicographic order equals creation
|
||||
// order at millisecond resolution, which the schema relies on for cursor
|
||||
// pagination and time-ordered message ids.
|
||||
package ulid
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
|
||||
// Len is the length of the canonical string form.
|
||||
const Len = 26
|
||||
|
||||
var decode [256]byte
|
||||
|
||||
func init() {
|
||||
for i := range decode {
|
||||
decode[i] = 0xFF
|
||||
}
|
||||
for i := 0; i < len(alphabet); i++ {
|
||||
decode[alphabet[i]] = byte(i)
|
||||
// Crockford base32 is case-insensitive.
|
||||
decode[alphabet[i]|0x20] = byte(i)
|
||||
}
|
||||
}
|
||||
|
||||
// New returns a fresh ULID for the current time.
|
||||
func New() string { return At(time.Now()) }
|
||||
|
||||
// At returns a ULID whose timestamp component is t (randomness still fresh).
|
||||
func At(t time.Time) string {
|
||||
var b [16]byte
|
||||
ms := uint64(t.UnixMilli())
|
||||
b[0] = byte(ms >> 40)
|
||||
b[1] = byte(ms >> 32)
|
||||
b[2] = byte(ms >> 24)
|
||||
b[3] = byte(ms >> 16)
|
||||
b[4] = byte(ms >> 8)
|
||||
b[5] = byte(ms)
|
||||
if _, err := rand.Read(b[6:]); err != nil {
|
||||
// crypto/rand never fails on supported platforms; if it does, the
|
||||
// process cannot safely generate ids at all.
|
||||
panic(fmt.Sprintf("ulid: crypto/rand failed: %v", err))
|
||||
}
|
||||
|
||||
// Base32-encode 128 bits into 26 chars (130-bit capacity, top 2 bits 0):
|
||||
// consume bytes from the least-significant end, emit 5-bit groups
|
||||
// right-to-left, which yields the canonical big-endian representation.
|
||||
var out [Len]byte
|
||||
acc, nbits, j := uint32(0), 0, Len-1
|
||||
for i := 15; i >= 0; i-- {
|
||||
acc |= uint32(b[i]) << nbits
|
||||
nbits += 8
|
||||
for nbits >= 5 && j > 0 {
|
||||
out[j] = alphabet[acc&31]
|
||||
acc >>= 5
|
||||
nbits -= 5
|
||||
j--
|
||||
}
|
||||
}
|
||||
out[0] = alphabet[acc&31] // remaining 3 timestamp bits
|
||||
return string(out[:])
|
||||
}
|
||||
|
||||
// IsValid reports whether s is a well-formed ULID.
|
||||
func IsValid(s string) bool {
|
||||
if len(s) != Len {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < Len; i++ {
|
||||
if decode[s[i]] == 0xFF {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// First char encodes the top 3 bits of a 48-bit value; > '7' overflows.
|
||||
return decode[s[0]] <= 7
|
||||
}
|
||||
|
||||
// Timestamp extracts the embedded creation time.
|
||||
func Timestamp(s string) (time.Time, error) {
|
||||
if !IsValid(s) {
|
||||
return time.Time{}, fmt.Errorf("ulid: invalid id %q", s)
|
||||
}
|
||||
var ms uint64
|
||||
for i := 0; i < 10; i++ { // first 10 chars = 50 bits, low 48 are the timestamp
|
||||
ms = ms<<5 | uint64(decode[s[i]])
|
||||
}
|
||||
return time.UnixMilli(int64(ms)).UTC(), nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package ulid
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewShape(t *testing.T) {
|
||||
id := New()
|
||||
if len(id) != Len {
|
||||
t.Fatalf("len = %d, want %d", len(id), Len)
|
||||
}
|
||||
for i := 0; i < len(id); i++ {
|
||||
if !strings.ContainsRune(alphabet, rune(id[i])) {
|
||||
t.Fatalf("char %q at %d not in Crockford alphabet", id[i], i)
|
||||
}
|
||||
}
|
||||
if !IsValid(id) {
|
||||
t.Fatalf("IsValid(%q) = false", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniqueness(t *testing.T) {
|
||||
const n = 10000
|
||||
seen := make(map[string]struct{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
id := New()
|
||||
if _, dup := seen[id]; dup {
|
||||
t.Fatalf("duplicate ULID %q after %d ids", id, i)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLexicographicTimeOrdering(t *testing.T) {
|
||||
base := time.Now()
|
||||
var ids []string
|
||||
for i := 0; i < 50; i++ {
|
||||
ids = append(ids, At(base.Add(time.Duration(i)*time.Millisecond)))
|
||||
}
|
||||
if !sort.StringsAreSorted(ids) {
|
||||
t.Fatal("ULIDs with increasing timestamps are not lexicographically sorted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimestampRoundTrip(t *testing.T) {
|
||||
tests := []time.Time{
|
||||
time.UnixMilli(0),
|
||||
time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
||||
time.Now(),
|
||||
}
|
||||
for _, want := range tests {
|
||||
id := At(want)
|
||||
got, err := Timestamp(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Timestamp(%q): %v", id, err)
|
||||
}
|
||||
if got.UnixMilli() != want.UnixMilli() {
|
||||
t.Errorf("timestamp = %v, want %v", got.UnixMilli(), want.UnixMilli())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{New(), true},
|
||||
{strings.ToLower(New()), true}, // Crockford decoding is case-insensitive
|
||||
{"", false},
|
||||
{"short", false},
|
||||
{strings.Repeat("0", 25), false},
|
||||
{strings.Repeat("0", 27), false},
|
||||
{strings.Repeat("U", 26), false}, // U not in alphabet
|
||||
{"8" + strings.Repeat("0", 25), false}, // timestamp overflow (>48 bits)
|
||||
{"7" + strings.Repeat("Z", 25), true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := IsValid(tt.in); got != tt.want {
|
||||
t.Errorf("IsValid(%q) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimestampRejectsInvalid(t *testing.T) {
|
||||
if _, err := Timestamp("definitely-not-a-ulid!!!!!"); err == nil {
|
||||
t.Fatal("want error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user