diff --git a/.gitignore b/.gitignore index 0880c7f..abf19d9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ __pycache__/ # Local build output /niko_trust + +# Local config (committed template is config.yaml.example) +/config.yaml diff --git a/cmd/server/main.go b/cmd/server/main.go index ce5c817..a48f2cd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -20,9 +20,10 @@ import ( ) func main() { - addr := flag.String("addr", ":8080", "listen address") - audience := flag.String("audience", "trust.n1ko.dev", "server audience bound into auth assertions") - data := flag.String("data", "", "directory to persist objects (empty = in-memory)") + configPath := flag.String("config", "config.yaml", "path to YAML config (missing file => defaults)") + addr := flag.String("addr", "", "listen address (overrides config)") + audience := flag.String("audience", "", "server audience bound into auth assertions (overrides config)") + data := flag.String("data", "", "directory to persist objects (overrides config)") powPutBits := flag.Int("pow-put-bits", 22, "proof-of-work difficulty for storing objects, leading zero bits (0 = off)") powAuthBits := flag.Int("pow-auth-bits", 18, "proof-of-work difficulty for auth challenge issuance, leading zero bits (0 = off)") ckptInterval := flag.Duration("ckpt-interval", time.Minute, "maximum time between signed checkpoints when the object set changed") @@ -32,10 +33,32 @@ func main() { bftTimeout := flag.Duration("bft-round-timeout", 2*time.Second, "BFT round timeout") flag.Parse() - logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) - srv := server.New(*audience, *data, + cfg, err := server.LoadConfig(*configPath) + if err != nil { + // A missing default config is fine; only a present-but-unreadable or + // malformed file is fatal. + if *configPath != "config.yaml" || !os.IsNotExist(err) { + slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})). + Error("load config", "path", *configPath, "err", err) + os.Exit(1) + } + } + + // Flags override the file. + if *addr != "" { + cfg.ListenAddr = *addr + } + if *audience != "" { + cfg.Audience = *audience + } + if *data != "" { + cfg.DataDir = *data + } + + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel(cfg.LogLevel)})) + srv := server.New(cfg, server.WithPow(*powPutBits, *powAuthBits), - server.WithCheckpoints(*data, server.CheckpointConfig{Interval: *ckptInterval, EveryN: *ckptEvery}), + server.WithCheckpoints(cfg.DataDir, server.CheckpointConfig{Interval: *ckptInterval, EveryN: *ckptEvery}), ) if *bftValidators != "" && *bftURLs != "" { srv.SetBFT(server.BFTConfig{ @@ -50,13 +73,13 @@ func main() { srv.StartBFT(ctx) defer stop() h := &http.Server{ - Addr: *addr, + Addr: cfg.ListenAddr, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second, } go func() { - logger.Info("trust relay listening", "addr", *addr, "audience", *audience) + logger.Info("trust relay listening", "addr", cfg.ListenAddr, "audience", cfg.Audience, "data_dir", cfg.DataDir) if err := h.ListenAndServe(); err != nil && err != http.ErrServerClosed { logger.Error("listen", "err", err) os.Exit(1) @@ -86,3 +109,11 @@ func splitCSV(s string) []string { } return out } + +func logLevel(s string) slog.Level { + var l slog.Level + if err := l.UnmarshalText([]byte(s)); err != nil { + return slog.LevelInfo + } + return l +} diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..aa2f2c6 --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,20 @@ +# Example relay configuration. Copy to config.yaml and edit. +# config.yaml itself is git-ignored; this file is the committed template. + +listen_addr: ":8080" # HTTP listen address +audience: "trust.n1ko.dev" # audience bound into auth assertions +data_dir: "" # "" = in-memory; set a path to persist objects +log_level: "info" # debug | info | warn | error + +# Per-IP fixed-window rate limits. +put_limit: 60 # POST /v1/objects per window +put_window: "1m" +challenge_limit: 30 # POST /v1/auth/challenge per window +challenge_window: "1m" + +max_per_subject: 1000 # max claims stored per subject address + +challenge_ttl: "5m" # validity of an issued auth challenge +session_ttl: "30m" # validity of a verified auth session + +max_body_bytes: 9216 # hard request-body cap (tce.MaxClaimTCE*2 + 1024) diff --git a/docs/API.md b/docs/API.md index a526fd5..1d6abc4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -146,3 +146,30 @@ decode the `TCE` to read the issuer/subject/fields. | 422 | Well-formed but rejected (quota / already answered) | | 429 | Rate limit exceeded | | 503 | Server not ready | + +## Configuration + +The server is configured from a YAML file (default `config.yaml`), with +command-line flags overriding individual keys. A missing `config.yaml` is not +an error: the server runs with built-in defaults. Copy `config.yaml.example` to +`get started`. + +```yaml +listen_addr: ":8080" # HTTP listen address +audience: "trust.n1ko.dev" # audience bound into auth assertions +data_dir: "" # "" = in-memory; a path persists objects to disk +log_level: "info" # debug | info | warn | error + +put_limit: 60 # POST /v1/objects per window, per IP +put_window: "1m" +challenge_limit: 30 # POST /v1/auth/challenge per window, per IP +challenge_window: "1m" +max_per_subject: 1000 # claim cap per subject address +challenge_ttl: "5m" # auth challenge validity +session_ttl: "30m" # verified session validity +max_body_bytes: 9216 # hard request-body cap +``` + +Flags: `-config ` (default `config.yaml`), `-addr`, `-audience`, `-data` +(any of these overrides the file). + diff --git a/go.mod b/go.mod index b141419..94cb4f1 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( filippo.io/edwards25519 v1.1.0 github.com/btcsuite/btcd/btcutil v1.1.6 github.com/coder/websocket v1.8.15 + gopkg.in/yaml.v3 v3.0.1 lukechampine.com/blake3 v1.4.1 ) diff --git a/go.sum b/go.sum index 7e6367b..9c234ad 100644 --- a/go.sum +++ b/go.sum @@ -101,6 +101,7 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= @@ -108,6 +109,7 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= diff --git a/internal/lightnode/integration_test.go b/internal/lightnode/integration_test.go index 8297d87..d176ceb 100644 --- a/internal/lightnode/integration_test.go +++ b/internal/lightnode/integration_test.go @@ -33,7 +33,7 @@ type relayFixture struct { func newRelay(t *testing.T, dir string) *relayFixture { t.Helper() - srv := server.New("trust.n1ko.dev", dir, + srv := server.New(server.Config{Audience: "trust.n1ko.dev", DataDir: dir}, server.WithCheckpoints(dir, server.CheckpointConfig{Interval: time.Hour, EveryN: 1})) ctx, cancel := context.WithCancel(context.Background()) srv.StartCheckpoints(ctx) diff --git a/internal/server/checkpoint_test.go b/internal/server/checkpoint_test.go index fd2f28d..0ec9656 100644 --- a/internal/server/checkpoint_test.go +++ b/internal/server/checkpoint_test.go @@ -35,7 +35,7 @@ type ckptResponse struct { func newCkptServer(t *testing.T, dir string) *httptest.Server { t.Helper() - srv := server.New("trust.n1ko.dev", dir, + srv := server.New(server.Config{Audience: "trust.n1ko.dev", DataDir: dir}, server.WithCheckpoints(dir, server.CheckpointConfig{Interval: time.Hour, EveryN: 1}), ) ts := httptest.NewServer(srv.Handler()) @@ -243,7 +243,7 @@ func TestProofEndpointsVerifyAgainstSmt(t *testing.T) { func TestCheckpointRestartPersistsRootAndHistory(t *testing.T) { dir := t.TempDir() - srv1 := server.New("trust.n1ko.dev", dir, + srv1 := server.New(server.Config{Audience: "trust.n1ko.dev", DataDir: dir}, server.WithCheckpoints(dir, server.CheckpointConfig{Interval: time.Hour, EveryN: 1})) ts1 := httptest.NewServer(srv1.Handler()) for i := 0; i < 2; i++ { @@ -259,7 +259,7 @@ func TestCheckpointRestartPersistsRootAndHistory(t *testing.T) { t.Fatal("relay key not persisted:", err) } - srv2 := server.New("trust.n1ko.dev", dir, + srv2 := server.New(server.Config{Audience: "trust.n1ko.dev", DataDir: dir}, server.WithCheckpoints(dir, server.CheckpointConfig{Interval: time.Hour, EveryN: 1})) ts2 := httptest.NewServer(srv2.Handler()) defer ts2.Close() diff --git a/internal/server/config.go b/internal/server/config.go new file mode 100644 index 0000000..f5a4757 --- /dev/null +++ b/internal/server/config.go @@ -0,0 +1,136 @@ +package server + +import ( + "os" + "time" + + "git.n1ko.dev/Niko/niko_trust/internal/tce" + "gopkg.in/yaml.v3" +) + +// Config holds every operator-tunable knob for the relay. All fields have sane +// defaults (see DefaultConfig); a YAML file overrides only the keys it sets. +type Config struct { + ListenAddr string // HTTP listen address, e.g. ":8080" + Audience string // audience bound into auth assertions + DataDir string // directory to persist objects ("" = in-memory) + LogLevel string // debug | info | warn | error + + // Per-IP fixed-window rate limits. + PutLimit int // POST /v1/objects requests per window + PutWindow time.Duration // window for PutLimit + ChallengeLimit int // POST /v1/auth/challenge requests per window + ChallengeWindow time.Duration // window for ChallengeLimit + + MaxPerSubject int // max claims stored per subject address + + ChallengeTTL time.Duration // validity of an issued auth challenge + SessionTTL time.Duration // validity of a verified auth session + + MaxBodyBytes int64 // hard cap on any request body +} + +// configFile mirrors Config but keeps durations as strings so they can be +// written as "5m" / "1m" in YAML (yaml.v3 does not parse time.Duration). +type configFile struct { + ListenAddr string `yaml:"listen_addr"` + Audience string `yaml:"audience"` + DataDir string `yaml:"data_dir"` + LogLevel string `yaml:"log_level"` + PutLimit int `yaml:"put_limit"` + PutWindow string `yaml:"put_window"` + ChallengeLimit int `yaml:"challenge_limit"` + ChallengeWindow string `yaml:"challenge_window"` + MaxPerSubject int `yaml:"max_per_subject"` + ChallengeTTL string `yaml:"challenge_ttl"` + SessionTTL string `yaml:"session_ttl"` + MaxBodyBytes int64 `yaml:"max_body_bytes"` +} + +// DefaultConfig returns the built-in defaults. +func DefaultConfig() Config { + return Config{ + ListenAddr: ":8080", + Audience: "trust.n1ko.dev", + LogLevel: "info", + PutLimit: 60, + PutWindow: time.Minute, + ChallengeLimit: 30, + ChallengeWindow: time.Minute, + MaxPerSubject: 1000, + ChallengeTTL: 5 * time.Minute, + SessionTTL: 30 * time.Minute, + MaxBodyBytes: tce.MaxClaimTCE*2 + 1024, + } +} + +// LoadConfig reads a YAML config file. A missing file yields the defaults +// (so the server can run config-less); any other read or parse error is +// returned. +func LoadConfig(path string) (Config, error) { + cfg := DefaultConfig() + if path == "" { + return cfg, nil + } + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return cfg, nil + } + return cfg, err + } + return ParseConfig(b) +} + +// ParseConfig decodes YAML bytes into a Config, layering overrides on top of +// the defaults. +func ParseConfig(b []byte) (Config, error) { + cfg := DefaultConfig() + var f configFile + if err := yaml.Unmarshal(b, &f); err != nil { + return cfg, err + } + applyConfigFile(&cfg, &f) + return cfg, nil +} + +func applyConfigFile(cfg *Config, f *configFile) { + if f.ListenAddr != "" { + cfg.ListenAddr = f.ListenAddr + } + if f.Audience != "" { + cfg.Audience = f.Audience + } + if f.DataDir != "" { + cfg.DataDir = f.DataDir + } + if f.LogLevel != "" { + cfg.LogLevel = f.LogLevel + } + if f.PutLimit != 0 { + cfg.PutLimit = f.PutLimit + } + cfg.PutWindow = parseDur(f.PutWindow, cfg.PutWindow) + if f.ChallengeLimit != 0 { + cfg.ChallengeLimit = f.ChallengeLimit + } + cfg.ChallengeWindow = parseDur(f.ChallengeWindow, cfg.ChallengeWindow) + if f.MaxPerSubject != 0 { + cfg.MaxPerSubject = f.MaxPerSubject + } + cfg.ChallengeTTL = parseDur(f.ChallengeTTL, cfg.ChallengeTTL) + cfg.SessionTTL = parseDur(f.SessionTTL, cfg.SessionTTL) + if f.MaxBodyBytes != 0 { + cfg.MaxBodyBytes = f.MaxBodyBytes + } +} + +func parseDur(s string, def time.Duration) time.Duration { + if s == "" { + return def + } + if d, err := time.ParseDuration(s); err == nil { + return d + } + return def +} diff --git a/internal/server/config_test.go b/internal/server/config_test.go new file mode 100644 index 0000000..cb0bd3a --- /dev/null +++ b/internal/server/config_test.go @@ -0,0 +1,72 @@ +package server + +import ( + "testing" + "time" +) + +func TestDefaultConfig(t *testing.T) { + c := DefaultConfig() + if c.ListenAddr != ":8080" || c.Audience != "trust.n1ko.dev" { + t.Fatalf("unexpected defaults: %+v", c) + } + if c.PutLimit != 60 || c.PutWindow != time.Minute { + t.Fatalf("unexpected rate-limit defaults: %+v", c) + } + if c.MaxPerSubject != 1000 || c.ChallengeTTL != 5*time.Minute || c.SessionTTL != 30*time.Minute { + t.Fatalf("unexpected auth defaults: %+v", c) + } +} + +func TestParseConfig(t *testing.T) { + yaml := []byte(` +listen_addr: ":9090" +audience: "relay.example" +data_dir: "/var/lib/trust" +log_level: "debug" +put_limit: 10 +put_window: "2m" +challenge_limit: 5 +challenge_window: "30s" +max_per_subject: 50 +challenge_ttl: "1m" +session_ttl: "10m" +max_body_bytes: 2048 +`) + c, err := ParseConfig(yaml) + if err != nil { + t.Fatal(err) + } + if c.ListenAddr != ":9090" || c.Audience != "relay.example" || c.DataDir != "/var/lib/trust" || c.LogLevel != "debug" { + t.Fatalf("scalars not overridden: %+v", c) + } + if c.PutLimit != 10 || c.PutWindow != 2*time.Minute { + t.Fatalf("put limit not parsed: %+v", c) + } + if c.ChallengeLimit != 5 || c.ChallengeWindow != 30*time.Second { + t.Fatalf("challenge limit not parsed: %+v", c) + } + if c.MaxPerSubject != 50 || c.ChallengeTTL != time.Minute || c.SessionTTL != 10*time.Minute { + t.Fatalf("auth knobs not parsed: %+v", c) + } + if c.MaxBodyBytes != 2048 { + t.Fatalf("max_body_bytes not parsed: %+v", c) + } +} + +func TestParseConfigPartial(t *testing.T) { + // Unset keys fall back to defaults; badly-formatted durations are ignored. + c, err := ParseConfig([]byte("put_limit: 7\nput_window: \"not-a-duration\"\n")) + if err != nil { + t.Fatal(err) + } + if c.PutLimit != 7 { + t.Fatalf("put_limit should be 7, got %d", c.PutLimit) + } + if c.PutWindow != time.Minute { + t.Fatalf("invalid duration should fall back to default, got %v", c.PutWindow) + } + if c.MaxPerSubject != 1000 { + t.Fatalf("unset max_per_subject should keep default, got %d", c.MaxPerSubject) + } +} diff --git a/internal/server/gossip_test.go b/internal/server/gossip_test.go index e1ba725..e317122 100644 --- a/internal/server/gossip_test.go +++ b/internal/server/gossip_test.go @@ -51,7 +51,7 @@ func postGossip(t *testing.T, url string, body map[string]any) *http.Response { } func TestGossipAcceptsValidHead(t *testing.T) { - srv := server.New("trust.n1ko.dev", "", server.WithCheckpoints("", server.CheckpointConfig{Interval: time.Hour})) + srv := server.New(server.Config{Audience: "trust.n1ko.dev"}, server.WithCheckpoints("", server.CheckpointConfig{Interval: time.Hour})) ts := httptest.NewServer(srv.Handler()) defer ts.Close() @@ -80,7 +80,7 @@ func TestGossipAcceptsValidHead(t *testing.T) { } func TestGossipRejectsBadSignatureAndMismatchedID(t *testing.T) { - srv := server.New("trust.n1ko.dev", "") + srv := server.New(server.Config{Audience: "trust.n1ko.dev"}) ts := httptest.NewServer(srv.Handler()) defer ts.Close() @@ -107,7 +107,7 @@ func TestGossipRejectsBadSignatureAndMismatchedID(t *testing.T) { } func TestGossipDetectsEquivocation(t *testing.T) { - srv := server.New("trust.n1ko.dev", "") + srv := server.New(server.Config{Audience: "trust.n1ko.dev"}) ts := httptest.NewServer(srv.Handler()) defer ts.Close() diff --git a/internal/server/pow_test.go b/internal/server/pow_test.go index 398027f..169cf99 100644 --- a/internal/server/pow_test.go +++ b/internal/server/pow_test.go @@ -22,7 +22,7 @@ import ( // enough to solve inline in tests. func newPowServer(t *testing.T) *httptest.Server { t.Helper() - srv := server.New("trust.n1ko.dev", "", server.WithPow(8, 8)) + srv := server.New(server.Config{Audience: "trust.n1ko.dev"}, server.WithPow(8, 8)) return httptest.NewServer(srv.Handler()) } diff --git a/internal/server/server.go b/internal/server/server.go index e0680c3..85ffe88 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -20,17 +20,6 @@ import ( "git.n1ko.dev/Niko/niko_trust/internal/transport" ) -// maxBodyBytes bounds the size of any request body the relay will read. A -// content-addressed object is bounded by protocol limits; this is a hard cap on -// the wire envelope so a single client cannot exhaust server memory. -const maxBodyBytes = tce.MaxClaimTCE*2 + 1024 - -// challengeTTL is how long an issued auth challenge stays valid. -const challengeTTL = 5 * time.Minute - -// sessionTTL is how long a verified auth session stays valid. -const sessionTTL = 30 * time.Minute - // Server is the trust relay: it stores signed objects and brokers // authentication, without ever holding a signing key or making authorization // decisions (INV-1, INV-5). @@ -40,6 +29,10 @@ type Server struct { metrics *Metrics ready bool + maxBodyBytes int64 + challengeTTL time.Duration + sessionTTL time.Duration + putLimiter *ipLimiter challengeLimiter *ipLimiter powChallengeLimiter *ipLimiter @@ -73,21 +66,57 @@ type session struct { expiry time.Time } -// New builds a relay that binds AuthAssertions to the given audience (its -// hostname, e.g. "trust.n1ko.dev"). If dataDir is non-empty, objects are -// persisted there across restarts. -func New(audience, dataDir string, opts ...Option) *Server { +// New builds a relay from the given Config. Any zero-valued field falls back to +// DefaultConfig, so callers may pass a partially-populated Config. Network-layer +// features (PoW, checkpoints, BFT) are layered on via Options. +func New(cfg Config, opts ...Option) *Server { + if cfg.ListenAddr == "" { + cfg.ListenAddr = ":8080" + } + if cfg.Audience == "" { + cfg.Audience = "trust.n1ko.dev" + } + if cfg.LogLevel == "" { + cfg.LogLevel = "info" + } + if cfg.MaxBodyBytes <= 0 { + cfg.MaxBodyBytes = tce.MaxClaimTCE*2 + 1024 + } + if cfg.PutLimit <= 0 { + cfg.PutLimit = 60 + } + if cfg.PutWindow <= 0 { + cfg.PutWindow = time.Minute + } + if cfg.ChallengeLimit <= 0 { + cfg.ChallengeLimit = 30 + } + if cfg.ChallengeWindow <= 0 { + cfg.ChallengeWindow = time.Minute + } + if cfg.MaxPerSubject <= 0 { + cfg.MaxPerSubject = 1000 + } + if cfg.ChallengeTTL <= 0 { + cfg.ChallengeTTL = 5 * time.Minute + } + if cfg.SessionTTL <= 0 { + cfg.SessionTTL = 30 * time.Minute + } s := &Server{ - store: NewStore(dataDir), - audience: audience, + store: NewStore(cfg.DataDir, cfg.MaxPerSubject), + audience: cfg.Audience, metrics: NewMetrics(), + maxBodyBytes: cfg.MaxBodyBytes, + challengeTTL: cfg.ChallengeTTL, + sessionTTL: cfg.SessionTTL, challenges: make(map[string]time.Time), powChallenges: make(map[string]powChallengeRecord), sessions: make(map[string]session), gossip: newGossipState(), ws: newWSHub(), - putLimiter: newIPLimiter(60, time.Minute), - challengeLimiter: newIPLimiter(30, time.Minute), + putLimiter: newIPLimiter(cfg.PutLimit, cfg.PutWindow), + challengeLimiter: newIPLimiter(cfg.ChallengeLimit, cfg.ChallengeWindow), powChallengeLimiter: newIPLimiter(powChallengeLimitPerMin, time.Minute), } for _, opt := range opts { @@ -169,7 +198,7 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) { } func (s *Server) HandlePut(w http.ResponseWriter, r *http.Request) { - req, ok := decodeWire(w, r) + req, ok := s.decodeWire(w, r) if !ok { return } @@ -408,7 +437,7 @@ func (s *Server) handleChallenge(w http.ResponseWriter, r *http.Request) { // auth attempts must pay per attempt, not merely per IP per minute. // The body is optional when no PoW tier is configured. var body wireRequest - raw := readCapped(w, r) + raw := s.readCapped(w, r) if len(bytes.TrimSpace(raw)) > 0 { if err := json.Unmarshal(raw, &body); err != nil { writeErr(w, http.StatusBadRequest, "bad envelope") @@ -433,14 +462,14 @@ func (s *Server) handleChallenge(w http.ResponseWriter, r *http.Request) { delete(s.challenges, k) } } - s.challenges[chHex] = nowTs.Add(challengeTTL) + s.challenges[chHex] = nowTs.Add(s.challengeTTL) s.mu.Unlock() s.metrics.inc(&s.metrics.challengesIssued) writeJSON(w, http.StatusOK, map[string]string{"challenge": chHex}) } func (s *Server) handleAssert(w http.ResponseWriter, r *http.Request) { - env, ok := decodeEnvelope(w, r) + env, ok := s.decodeEnvelope(w, r) if !ok { return } @@ -484,7 +513,7 @@ func (s *Server) handleAssert(w http.ResponseWriter, r *http.Request) { tokHex := hex.EncodeToString(tok) id := identityFromPubKey(assert.PubKey) s.mu.Lock() - s.sessions[tokHex] = session{identity: id, scope: assert.Scope, expiry: now().Add(sessionTTL)} + s.sessions[tokHex] = session{identity: id, scope: assert.Scope, expiry: now().Add(s.sessionTTL)} s.metrics.sessionsActive.Add(1) s.mu.Unlock() s.metrics.inc(&s.metrics.assertionsOK) @@ -544,8 +573,8 @@ type wireRequest struct { // decodeWire reads a JSON request body from the request, enforcing the // server-wide body cap first. -func decodeWire(w http.ResponseWriter, r *http.Request) (*wireRequest, bool) { - r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) +func (s *Server) decodeWire(w http.ResponseWriter, r *http.Request) (*wireRequest, bool) { + r.Body = http.MaxBytesReader(w, r.Body, s.maxBodyBytes) var req wireRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { var maxErr *http.MaxBytesError @@ -561,8 +590,8 @@ func decodeWire(w http.ResponseWriter, r *http.Request) (*wireRequest, bool) { // decodeEnvelope adapts decodeWire to the plain envelope form for callers // that do not care about admission control. -func decodeEnvelope(w http.ResponseWriter, r *http.Request) (*transport.Envelope, bool) { - req, ok := decodeWire(w, r) +func (s *Server) decodeEnvelope(w http.ResponseWriter, r *http.Request) (*transport.Envelope, bool) { + req, ok := s.decodeWire(w, r) if !ok { return nil, false } @@ -570,8 +599,8 @@ func decodeEnvelope(w http.ResponseWriter, r *http.Request) (*transport.Envelope } // readCapped reads the request body under the server-wide cap. -func readCapped(w http.ResponseWriter, r *http.Request) []byte { - raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes)) +func (s *Server) readCapped(w http.ResponseWriter, r *http.Request) []byte { + raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, s.maxBodyBytes)) if err != nil { return nil } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 4c8c75e..ddc7ef5 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -22,7 +22,7 @@ import ( func newTestServer(t *testing.T) *httptest.Server { t.Helper() - srv := server.New("trust.n1ko.dev", "") + srv := server.New(server.Config{Audience: "trust.n1ko.dev"}) return httptest.NewServer(srv.Handler()) } @@ -425,7 +425,7 @@ func TestStorePersistsAcrossRestart(t *testing.T) { dir := t.TempDir() // Start one server, publish an object. - s1 := server.New("trust.n1ko.dev", dir) + s1 := server.New(server.Config{Audience: "trust.n1ko.dev", DataDir: dir}) issuer, _ := signer.Generate() c := &protocol.Claim{ Issuer: issuer.Public(), @@ -444,7 +444,7 @@ func TestStorePersistsAcrossRestart(t *testing.T) { } // A fresh server over the same directory must recover the object. - s2 := server.New("trust.n1ko.dev", dir) + s2 := server.New(server.Config{Audience: "trust.n1ko.dev", DataDir: dir}) env, err := s2.Store().Get(id) if err != nil { t.Fatalf("object not recovered after restart: %v", err) @@ -758,7 +758,7 @@ func TestPagination(t *testing.T) { func TestPerSubjectQuota(t *testing.T) { // Exercise handlePut directly (bypassing the HTTP rate limiter) so we can // publish enough distinct claims to reach the per-subject quota. - srv := server.New("trust.n1ko.dev", "") + srv := server.New(server.Config{Audience: "trust.n1ko.dev"}) issuer, _ := signer.Generate() subject, _ := signer.Generate() diff --git a/internal/server/store.go b/internal/server/store.go index 41e5b40..b1b0850 100644 --- a/internal/server/store.go +++ b/internal/server/store.go @@ -61,16 +61,17 @@ type Store struct { // root is a function of the set alone, so restarts and insertion order // cannot change it (docs/CHECKPOINT.md). trie *smt.Trie + + // maxPerSubject bounds how many claims a single subject may have in the + // store, to keep the in-memory indexes from growing without bound under a + // hostile or buggy publisher. + maxPerSubject int } -// maxPerSubject bounds how many claims a single subject may have in the store, -// to keep the in-memory indexes from growing without bound under a hostile or -// buggy publisher. -const maxPerSubject = 1000 - // NewStore returns a store. If dir is non-empty, existing objects are loaded -// from disk and every subsequent Put is persisted there. -func NewStore(dir string) *Store { +// from disk and every subsequent Put is persisted there. maxPerSubject is the +// per-subject claim cap. +func NewStore(dir string, maxPerSubject int) *Store { s := &Store{ dir: dir, byID: make(map[string]*transport.Envelope), @@ -83,6 +84,7 @@ func NewStore(dir string) *Store { answeredRequests: make(map[string]struct{}), confirmedRotations: make(map[string]struct{}), trie: smt.New(), + maxPerSubject: maxPerSubject, } if dir != "" { s.load() @@ -114,7 +116,7 @@ func (s *Store) Put(tceBytes, sig []byte, suppliedID string) (string, bool, erro } if claim, ok := obj.(*protocol.Claim); ok { sub := transport.AddrOf(claim.Subject) - if len(s.bySubject[sub]) >= maxPerSubject { + if len(s.bySubject[sub]) >= s.maxPerSubject { return "", false, fmt.Errorf("server: subject %s quota exceeded", sub) } }