Add Blossom bandwidth limiting and tune rate limiters (v0.49.0)
Some checks failed
Go / build-and-release (push) Has been cancelled

- Add token-bucket bandwidth rate limiting for Blossom uploads
  - ORLY_BLOSSOM_RATE_LIMIT enables limiting (default: false)
  - ORLY_BLOSSOM_DAILY_LIMIT_MB sets daily limit (default: 10MB)
  - ORLY_BLOSSOM_BURST_LIMIT_MB sets burst cap (default: 50MB)
  - Followed users, admins, owners are exempt (unlimited)
- Change emergency mode throttling from exponential to linear scaling
  - Old: 16x multiplier at emergency threshold entry
  - New: 1x at threshold, +1x per 20% excess pressure
- Reduce follows ACL throttle increment from 200ms to 25ms per event
- Update dependencies

Files modified:
- app/blossom.go: Pass rate limit config to blossom server
- app/config/config.go: Add Blossom rate limit config options
- pkg/blossom/ratelimit.go: New bandwidth limiter implementation
- pkg/blossom/server.go: Add rate limiter integration
- pkg/blossom/handlers.go: Check rate limits on upload/mirror/media
- pkg/ratelimit/limiter.go: Linear emergency throttling
- pkg/acl/follows.go: Reduce default throttle increment

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
woikos
2026-01-14 08:09:39 +01:00
parent 205f23fc0c
commit 9da1784b1b
17 changed files with 321 additions and 44 deletions

View File

@@ -20,8 +20,12 @@ func initializeBlossomServer(
blossomCfg := &blossom.Config{
BaseURL: "", // Will be set dynamically per request
MaxBlobSize: 100 * 1024 * 1024, // 100MB default
AllowedMimeTypes: nil, // Allow all MIME types by default
AllowedMimeTypes: nil, // Allow all MIME types by default
RequireAuth: cfg.AuthRequired || cfg.AuthToWrite,
// Rate limiting for non-followed users
RateLimitEnabled: cfg.BlossomRateLimitEnabled,
DailyLimitMB: cfg.BlossomDailyLimitMB,
BurstLimitMB: cfg.BlossomBurstLimitMB,
}
// Create blossom server with relay's ACL registry
@@ -31,7 +35,12 @@ func initializeBlossomServer(
// We'll need to modify the handler to inject the baseURL per request
// For now, we'll use a middleware approach
log.I.F("blossom server initialized with ACL mode: %s", cfg.ACLMode)
if cfg.BlossomRateLimitEnabled {
log.I.F("blossom server initialized with ACL mode: %s, rate limit: %dMB/day (burst: %dMB)",
cfg.ACLMode, cfg.BlossomDailyLimitMB, cfg.BlossomBurstLimitMB)
} else {
log.I.F("blossom server initialized with ACL mode: %s", cfg.ACLMode)
}
return bs, nil
}

View File

@@ -69,13 +69,18 @@ type C struct {
// Progressive throttle for follows ACL mode - allows non-followed users to write with increasing delay
FollowsThrottleEnabled bool `env:"ORLY_FOLLOWS_THROTTLE" default:"false" usage:"enable progressive delay for non-followed users in follows ACL mode"`
FollowsThrottlePerEvent time.Duration `env:"ORLY_FOLLOWS_THROTTLE_INCREMENT" default:"200ms" usage:"delay added per event for non-followed users"`
FollowsThrottlePerEvent time.Duration `env:"ORLY_FOLLOWS_THROTTLE_INCREMENT" default:"25ms" usage:"delay added per event for non-followed users"`
FollowsThrottleMaxDelay time.Duration `env:"ORLY_FOLLOWS_THROTTLE_MAX" default:"60s" usage:"maximum throttle delay cap"`
// Blossom blob storage service settings
BlossomEnabled bool `env:"ORLY_BLOSSOM_ENABLED" default:"true" usage:"enable Blossom blob storage server (only works with Badger backend)"`
BlossomServiceLevels string `env:"ORLY_BLOSSOM_SERVICE_LEVELS" usage:"comma-separated list of service levels in format: name:storage_mb_per_sat_per_month (e.g., basic:1,premium:10)"`
// Blossom upload rate limiting (for non-followed users)
BlossomRateLimitEnabled bool `env:"ORLY_BLOSSOM_RATE_LIMIT" default:"false" usage:"enable upload rate limiting for non-followed users"`
BlossomDailyLimitMB int64 `env:"ORLY_BLOSSOM_DAILY_LIMIT_MB" default:"10" usage:"daily upload limit in MB for non-followed users (EMA averaged)"`
BlossomBurstLimitMB int64 `env:"ORLY_BLOSSOM_BURST_LIMIT_MB" default:"50" usage:"max burst upload in MB (bucket cap)"`
// Web UI and dev mode settings
WebDisableEmbedded bool `env:"ORLY_WEB_DISABLE" default:"false" usage:"disable serving the embedded web UI; useful for hot-reload during development"`
WebDevProxyURL string `env:"ORLY_WEB_DEV_PROXY_URL" usage:"when ORLY_WEB_DISABLE is true, reverse-proxy non-API paths to this dev server URL (e.g. http://localhost:5173)"`

View File

@@ -124,6 +124,17 @@ func (s *Server) handleCashuKeysets(w http.ResponseWriter, r *http.Request) {
// handleCashuInfo handles GET /cashu/info - returns mint information.
func (s *Server) handleCashuInfo(w http.ResponseWriter, r *http.Request) {
// CORS headers for browser-based CAT support detection
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept")
// Handle preflight
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if s.CashuIssuer == nil {
http.Error(w, "Cashu tokens not enabled", http.StatusNotImplemented)
return

View File

@@ -21,7 +21,7 @@ import (
)
func (l *Listener) HandleEvent(msg []byte) (err error) {
log.D.F("HandleEvent: START handling event: %s", msg)
log.I.F("HandleEvent: START handling event: %s", string(msg[:min(200, len(msg))]))
// 1. Raw JSON validation (before unmarshal) - use validation service
if result := l.eventValidator.ValidateRawJSON(msg); !result.Valid {
@@ -231,6 +231,11 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
// Authorization check (policy + ACL) - use authorization service
decision := l.eventAuthorizer.Authorize(env.E, l.authedPubkey.Load(), l.remote, env.E.Kind)
// Debug: log ephemeral event authorization
if env.E.Kind >= 20000 && env.E.Kind < 30000 {
log.I.F("ephemeral auth check: kind %d, allowed=%v, reason=%s",
env.E.Kind, decision.Allowed, decision.DenyReason)
}
if !decision.Allowed {
log.D.F("HandleEvent: authorization denied: %s (requireAuth=%v)", decision.DenyReason, decision.RequireAuth)
if decision.RequireAuth {
@@ -256,14 +261,17 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
log.I.F("HandleEvent: authorized with access level %s", decision.AccessLevel)
// Progressive throttle for follows ACL mode (delays non-followed users)
if delay := l.getFollowsThrottleDelay(env.E); delay > 0 {
log.D.F("HandleEvent: applying progressive throttle delay of %v for %0x from %s",
delay, env.E.Pubkey, l.remote)
select {
case <-l.ctx.Done():
return l.ctx.Err()
case <-time.After(delay):
// Delay completed, continue processing
// Skip throttle if a Cashu Access Token is present (authenticated via CAT)
if l.cashuToken == nil {
if delay := l.getFollowsThrottleDelay(env.E); delay > 0 {
log.D.F("HandleEvent: applying progressive throttle delay of %v for %0x from %s",
delay, env.E.Pubkey, l.remote)
select {
case <-l.ctx.Done():
return l.ctx.Err()
case <-time.After(delay):
// Delay completed, continue processing
}
}
}

View File

@@ -159,12 +159,26 @@ func (p *P) Deliver(ev *event.E) {
sub Subscription
}
var deliveries []delivery
// Debug: log ephemeral event delivery attempts
isEphemeral := ev.Kind >= 20000 && ev.Kind < 30000
if isEphemeral {
var tagInfo string
if ev.Tags != nil {
tagInfo = string(ev.Tags.Marshal(nil))
}
log.I.F("ephemeral event kind %d, id %0x, checking %d connections for matches, tags: %s",
ev.Kind, ev.ID[:8], len(p.Map), tagInfo)
}
for w, subs := range p.Map {
for id, subscriber := range subs {
if subscriber.Match(ev) {
deliveries = append(
deliveries, delivery{w: w, id: id, sub: subscriber},
)
} else if isEphemeral {
// Debug: log why ephemeral events don't match
log.I.F("ephemeral event kind %d did NOT match subscription %s (filters: %s)",
ev.Kind, id, string(subscriber.S.Marshal(nil)))
}
}
}