niko_trust/internal/server/config.go
Niko Marmeladkov 3bf13fa488 Public SDK packages, proxy-aware rate limits, service login recipe
- internal/{address,identity,protocol,tce,transport,verify} -> pkg/ so
  external Go projects can import the verified core; invariant tests
  updated for the new paths
- Config.TrustProxy: key rate limiting by X-Forwarded-For when the relay
  sits behind a reverse proxy (off by default, header never trusted
  otherwise)
- examples/service + examples/approve: complete passwordless login round
  trip (mint request -> wallet approves -> local verify), run live in CI
- docs/SERVICE-GUIDE.md: the integration recipe
2026-08-26 12:49:54 +03:00

145 lines
4.2 KiB
Go

package server
import (
"os"
"time"
"git.n1ko.dev/Niko/niko_trust/pkg/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
// TrustProxy: when true, rate limiting keys clients by the
// X-Forwarded-For header sent by the reverse proxy in front of the
// relay instead of the socket address (which would collapse every
// visitor into one bucket). Only enable it when the relay is actually
// reachable exclusively through a proxy you control.
TrustProxy bool
}
// 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"`
TrustProxy bool `yaml:"trust_proxy"`
}
// 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
}