feat: YAML config for all server tunables
- server.Config + DefaultConfig + LoadConfig/ParseConfig (yaml.v3) - all operator knobs now configurable: listen addr, audience, data dir, log level, per-IP rate limits, per-subject quota, challenge/session TTLs, max body bytes - server.New takes Config; flags (-addr/-audience/-data/-config) override the file; missing config.yaml falls back to defaults - config.yaml.example committed as template; config.yaml git-ignored - tests for config defaults/parsing/partial override
This commit is contained in:
parent
20cc52c3a5
commit
d957108ac1
15 changed files with 381 additions and 58 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -14,3 +14,6 @@ __pycache__/
|
|||
|
||||
# Local build output
|
||||
/niko_trust
|
||||
|
||||
# Local config (committed template is config.yaml.example)
|
||||
/config.yaml
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
20
config.yaml.example
Normal file
20
config.yaml.example
Normal file
|
|
@ -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)
|
||||
27
docs/API.md
27
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 <path>` (default `config.yaml`), `-addr`, `-audience`, `-data`
|
||||
(any of these overrides the file).
|
||||
|
||||
|
|
|
|||
1
go.mod
1
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
|
||||
)
|
||||
|
||||
|
|
|
|||
2
go.sum
2
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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
136
internal/server/config.go
Normal file
136
internal/server/config.go
Normal file
|
|
@ -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
|
||||
}
|
||||
72
internal/server/config_test.go
Normal file
72
internal/server/config_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue