Compare commits
2 commits
20cc52c3a5
...
b5c0200491
| Author | SHA1 | Date | |
|---|---|---|---|
| b5c0200491 | |||
| d957108ac1 |
15 changed files with 382 additions and 58 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
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: "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)
|
||||||
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) |
|
| 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
1
go.mod
|
|
@ -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
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.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=
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
137
internal/server/config.go
Normal 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
|
||||||
|
}
|
||||||
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) {
|
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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue