Compare commits

...

2 commits

Author SHA1 Message Date
b5c0200491
feat: default DataDir to data for object persistence 2026-08-25 21:12:03 +03:00
d957108ac1
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
2026-08-25 21:11:49 +03:00
15 changed files with 382 additions and 58 deletions

3
.gitignore vendored
View file

@ -14,3 +14,6 @@ __pycache__/
# Local build output # Local build output
/niko_trust /niko_trust
# Local config (committed template is config.yaml.example)
/config.yaml

View file

@ -20,9 +20,10 @@ import (
) )
func main() { func main() {
addr := flag.String("addr", ":8080", "listen address") configPath := flag.String("config", "config.yaml", "path to YAML config (missing file => defaults)")
audience := flag.String("audience", "trust.n1ko.dev", "server audience bound into auth assertions") addr := flag.String("addr", "", "listen address (overrides config)")
data := flag.String("data", "", "directory to persist objects (empty = in-memory)") 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)") 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)") 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") 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") bftTimeout := flag.Duration("bft-round-timeout", 2*time.Second, "BFT round timeout")
flag.Parse() flag.Parse()
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) cfg, err := server.LoadConfig(*configPath)
srv := server.New(*audience, *data, 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.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 != "" { if *bftValidators != "" && *bftURLs != "" {
srv.SetBFT(server.BFTConfig{ srv.SetBFT(server.BFTConfig{
@ -50,13 +73,13 @@ func main() {
srv.StartBFT(ctx) srv.StartBFT(ctx)
defer stop() defer stop()
h := &http.Server{ h := &http.Server{
Addr: *addr, Addr: cfg.ListenAddr,
Handler: srv.Handler(), Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
} }
go func() { 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 { if err := h.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("listen", "err", err) logger.Error("listen", "err", err)
os.Exit(1) os.Exit(1)
@ -86,3 +109,11 @@ func splitCSV(s string) []string {
} }
return out 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
View 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: "data" # uncomment to persist objects (default "data"); "" = in-memory
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)

View file

@ -146,3 +146,30 @@ decode the `TCE` to read the issuer/subject/fields.
| 422 | Well-formed but rejected (quota / already answered) | | 422 | Well-formed but rejected (quota / already answered) |
| 429 | Rate limit exceeded | | 429 | Rate limit exceeded |
| 503 | Server not ready | | 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
View file

@ -6,6 +6,7 @@ require (
filippo.io/edwards25519 v1.1.0 filippo.io/edwards25519 v1.1.0
github.com/btcsuite/btcd/btcutil v1.1.6 github.com/btcsuite/btcd/btcutil v1.1.6
github.com/coder/websocket v1.8.15 github.com/coder/websocket v1.8.15
gopkg.in/yaml.v3 v3.0.1
lukechampine.com/blake3 v1.4.1 lukechampine.com/blake3 v1.4.1
) )

2
go.sum
View file

@ -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.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.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 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/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/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 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.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/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.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= 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 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=

View file

@ -33,7 +33,7 @@ type relayFixture struct {
func newRelay(t *testing.T, dir string) *relayFixture { func newRelay(t *testing.T, dir string) *relayFixture {
t.Helper() 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})) server.WithCheckpoints(dir, server.CheckpointConfig{Interval: time.Hour, EveryN: 1}))
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
srv.StartCheckpoints(ctx) srv.StartCheckpoints(ctx)

View file

@ -35,7 +35,7 @@ type ckptResponse struct {
func newCkptServer(t *testing.T, dir string) *httptest.Server { func newCkptServer(t *testing.T, dir string) *httptest.Server {
t.Helper() 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}), server.WithCheckpoints(dir, server.CheckpointConfig{Interval: time.Hour, EveryN: 1}),
) )
ts := httptest.NewServer(srv.Handler()) ts := httptest.NewServer(srv.Handler())
@ -243,7 +243,7 @@ func TestProofEndpointsVerifyAgainstSmt(t *testing.T) {
func TestCheckpointRestartPersistsRootAndHistory(t *testing.T) { func TestCheckpointRestartPersistsRootAndHistory(t *testing.T) {
dir := t.TempDir() 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})) server.WithCheckpoints(dir, server.CheckpointConfig{Interval: time.Hour, EveryN: 1}))
ts1 := httptest.NewServer(srv1.Handler()) ts1 := httptest.NewServer(srv1.Handler())
for i := 0; i < 2; i++ { for i := 0; i < 2; i++ {
@ -259,7 +259,7 @@ func TestCheckpointRestartPersistsRootAndHistory(t *testing.T) {
t.Fatal("relay key not persisted:", err) 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})) server.WithCheckpoints(dir, server.CheckpointConfig{Interval: time.Hour, EveryN: 1}))
ts2 := httptest.NewServer(srv2.Handler()) ts2 := httptest.NewServer(srv2.Handler())
defer ts2.Close() defer ts2.Close()

137
internal/server/config.go Normal file
View file

@ -0,0 +1,137 @@
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",
DataDir: "data", // persist objects; "" would be in-memory only
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
}

View 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)
}
}

View file

@ -51,7 +51,7 @@ func postGossip(t *testing.T, url string, body map[string]any) *http.Response {
} }
func TestGossipAcceptsValidHead(t *testing.T) { 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()) ts := httptest.NewServer(srv.Handler())
defer ts.Close() defer ts.Close()
@ -80,7 +80,7 @@ func TestGossipAcceptsValidHead(t *testing.T) {
} }
func TestGossipRejectsBadSignatureAndMismatchedID(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()) ts := httptest.NewServer(srv.Handler())
defer ts.Close() defer ts.Close()
@ -107,7 +107,7 @@ func TestGossipRejectsBadSignatureAndMismatchedID(t *testing.T) {
} }
func TestGossipDetectsEquivocation(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()) ts := httptest.NewServer(srv.Handler())
defer ts.Close() defer ts.Close()

View file

@ -22,7 +22,7 @@ import (
// enough to solve inline in tests. // enough to solve inline in tests.
func newPowServer(t *testing.T) *httptest.Server { func newPowServer(t *testing.T) *httptest.Server {
t.Helper() 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()) return httptest.NewServer(srv.Handler())
} }

View file

@ -20,17 +20,6 @@ import (
"git.n1ko.dev/Niko/niko_trust/internal/transport" "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 // Server is the trust relay: it stores signed objects and brokers
// authentication, without ever holding a signing key or making authorization // authentication, without ever holding a signing key or making authorization
// decisions (INV-1, INV-5). // decisions (INV-1, INV-5).
@ -40,6 +29,10 @@ type Server struct {
metrics *Metrics metrics *Metrics
ready bool ready bool
maxBodyBytes int64
challengeTTL time.Duration
sessionTTL time.Duration
putLimiter *ipLimiter putLimiter *ipLimiter
challengeLimiter *ipLimiter challengeLimiter *ipLimiter
powChallengeLimiter *ipLimiter powChallengeLimiter *ipLimiter
@ -73,21 +66,57 @@ type session struct {
expiry time.Time expiry time.Time
} }
// New builds a relay that binds AuthAssertions to the given audience (its // New builds a relay from the given Config. Any zero-valued field falls back to
// hostname, e.g. "trust.n1ko.dev"). If dataDir is non-empty, objects are // DefaultConfig, so callers may pass a partially-populated Config. Network-layer
// persisted there across restarts. // features (PoW, checkpoints, BFT) are layered on via Options.
func New(audience, dataDir string, opts ...Option) *Server { 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{ s := &Server{
store: NewStore(dataDir), store: NewStore(cfg.DataDir, cfg.MaxPerSubject),
audience: audience, audience: cfg.Audience,
metrics: NewMetrics(), metrics: NewMetrics(),
maxBodyBytes: cfg.MaxBodyBytes,
challengeTTL: cfg.ChallengeTTL,
sessionTTL: cfg.SessionTTL,
challenges: make(map[string]time.Time), challenges: make(map[string]time.Time),
powChallenges: make(map[string]powChallengeRecord), powChallenges: make(map[string]powChallengeRecord),
sessions: make(map[string]session), sessions: make(map[string]session),
gossip: newGossipState(), gossip: newGossipState(),
ws: newWSHub(), ws: newWSHub(),
putLimiter: newIPLimiter(60, time.Minute), putLimiter: newIPLimiter(cfg.PutLimit, cfg.PutWindow),
challengeLimiter: newIPLimiter(30, time.Minute), challengeLimiter: newIPLimiter(cfg.ChallengeLimit, cfg.ChallengeWindow),
powChallengeLimiter: newIPLimiter(powChallengeLimitPerMin, time.Minute), powChallengeLimiter: newIPLimiter(powChallengeLimitPerMin, time.Minute),
} }
for _, opt := range opts { 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) { func (s *Server) HandlePut(w http.ResponseWriter, r *http.Request) {
req, ok := decodeWire(w, r) req, ok := s.decodeWire(w, r)
if !ok { if !ok {
return 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. // auth attempts must pay per attempt, not merely per IP per minute.
// The body is optional when no PoW tier is configured. // The body is optional when no PoW tier is configured.
var body wireRequest var body wireRequest
raw := readCapped(w, r) raw := s.readCapped(w, r)
if len(bytes.TrimSpace(raw)) > 0 { if len(bytes.TrimSpace(raw)) > 0 {
if err := json.Unmarshal(raw, &body); err != nil { if err := json.Unmarshal(raw, &body); err != nil {
writeErr(w, http.StatusBadRequest, "bad envelope") writeErr(w, http.StatusBadRequest, "bad envelope")
@ -433,14 +462,14 @@ func (s *Server) handleChallenge(w http.ResponseWriter, r *http.Request) {
delete(s.challenges, k) delete(s.challenges, k)
} }
} }
s.challenges[chHex] = nowTs.Add(challengeTTL) s.challenges[chHex] = nowTs.Add(s.challengeTTL)
s.mu.Unlock() s.mu.Unlock()
s.metrics.inc(&s.metrics.challengesIssued) s.metrics.inc(&s.metrics.challengesIssued)
writeJSON(w, http.StatusOK, map[string]string{"challenge": chHex}) writeJSON(w, http.StatusOK, map[string]string{"challenge": chHex})
} }
func (s *Server) handleAssert(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAssert(w http.ResponseWriter, r *http.Request) {
env, ok := decodeEnvelope(w, r) env, ok := s.decodeEnvelope(w, r)
if !ok { if !ok {
return return
} }
@ -484,7 +513,7 @@ func (s *Server) handleAssert(w http.ResponseWriter, r *http.Request) {
tokHex := hex.EncodeToString(tok) tokHex := hex.EncodeToString(tok)
id := identityFromPubKey(assert.PubKey) id := identityFromPubKey(assert.PubKey)
s.mu.Lock() 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.metrics.sessionsActive.Add(1)
s.mu.Unlock() s.mu.Unlock()
s.metrics.inc(&s.metrics.assertionsOK) s.metrics.inc(&s.metrics.assertionsOK)
@ -544,8 +573,8 @@ type wireRequest struct {
// decodeWire reads a JSON request body from the request, enforcing the // decodeWire reads a JSON request body from the request, enforcing the
// server-wide body cap first. // server-wide body cap first.
func decodeWire(w http.ResponseWriter, r *http.Request) (*wireRequest, bool) { func (s *Server) decodeWire(w http.ResponseWriter, r *http.Request) (*wireRequest, bool) {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) r.Body = http.MaxBytesReader(w, r.Body, s.maxBodyBytes)
var req wireRequest var req wireRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
var maxErr *http.MaxBytesError 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 // decodeEnvelope adapts decodeWire to the plain envelope form for callers
// that do not care about admission control. // that do not care about admission control.
func decodeEnvelope(w http.ResponseWriter, r *http.Request) (*transport.Envelope, bool) { func (s *Server) decodeEnvelope(w http.ResponseWriter, r *http.Request) (*transport.Envelope, bool) {
req, ok := decodeWire(w, r) req, ok := s.decodeWire(w, r)
if !ok { if !ok {
return nil, false 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. // readCapped reads the request body under the server-wide cap.
func readCapped(w http.ResponseWriter, r *http.Request) []byte { func (s *Server) readCapped(w http.ResponseWriter, r *http.Request) []byte {
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes)) raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, s.maxBodyBytes))
if err != nil { if err != nil {
return nil return nil
} }

View file

@ -22,7 +22,7 @@ import (
func newTestServer(t *testing.T) *httptest.Server { func newTestServer(t *testing.T) *httptest.Server {
t.Helper() t.Helper()
srv := server.New("trust.n1ko.dev", "") srv := server.New(server.Config{Audience: "trust.n1ko.dev"})
return httptest.NewServer(srv.Handler()) return httptest.NewServer(srv.Handler())
} }
@ -425,7 +425,7 @@ func TestStorePersistsAcrossRestart(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
// Start one server, publish an object. // 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() issuer, _ := signer.Generate()
c := &protocol.Claim{ c := &protocol.Claim{
Issuer: issuer.Public(), Issuer: issuer.Public(),
@ -444,7 +444,7 @@ func TestStorePersistsAcrossRestart(t *testing.T) {
} }
// A fresh server over the same directory must recover the object. // 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) env, err := s2.Store().Get(id)
if err != nil { if err != nil {
t.Fatalf("object not recovered after restart: %v", err) t.Fatalf("object not recovered after restart: %v", err)
@ -758,7 +758,7 @@ func TestPagination(t *testing.T) {
func TestPerSubjectQuota(t *testing.T) { func TestPerSubjectQuota(t *testing.T) {
// Exercise handlePut directly (bypassing the HTTP rate limiter) so we can // Exercise handlePut directly (bypassing the HTTP rate limiter) so we can
// publish enough distinct claims to reach the per-subject quota. // 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() issuer, _ := signer.Generate()
subject, _ := signer.Generate() subject, _ := signer.Generate()

View file

@ -61,16 +61,17 @@ type Store struct {
// root is a function of the set alone, so restarts and insertion order // root is a function of the set alone, so restarts and insertion order
// cannot change it (docs/CHECKPOINT.md). // cannot change it (docs/CHECKPOINT.md).
trie *smt.Trie 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 // NewStore returns a store. If dir is non-empty, existing objects are loaded
// from disk and every subsequent Put is persisted there. // from disk and every subsequent Put is persisted there. maxPerSubject is the
func NewStore(dir string) *Store { // per-subject claim cap.
func NewStore(dir string, maxPerSubject int) *Store {
s := &Store{ s := &Store{
dir: dir, dir: dir,
byID: make(map[string]*transport.Envelope), byID: make(map[string]*transport.Envelope),
@ -83,6 +84,7 @@ func NewStore(dir string) *Store {
answeredRequests: make(map[string]struct{}), answeredRequests: make(map[string]struct{}),
confirmedRotations: make(map[string]struct{}), confirmedRotations: make(map[string]struct{}),
trie: smt.New(), trie: smt.New(),
maxPerSubject: maxPerSubject,
} }
if dir != "" { if dir != "" {
s.load() 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 { if claim, ok := obj.(*protocol.Claim); ok {
sub := transport.AddrOf(claim.Subject) 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) return "", false, fmt.Errorf("server: subject %s quota exceeded", sub)
} }
} }