diff --git a/.claude/settings.local.json b/.claude/settings.local.json index cc346ce..0483c69 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,12 +3,8 @@ "allow": [], "deny": [], "ask": [], - "additionalDirectories": [ - "/home/mleku/smesh", - "/home/mleku/Tourmaline", - "/home/mleku/Amber" - ] + "additionalDirectories": [] }, "outputStyle": "Default", - "MAX_THINKING_TOKENS": "8000" + "MAX_THINKING_TOKENS": "16000" } diff --git a/app/config/config.go b/app/config/config.go index 29d7e20..700bb86 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -67,6 +67,11 @@ type C struct { ClusterAdmins []string `env:"ORLY_CLUSTER_ADMINS" usage:"comma-separated list of npubs authorized to manage cluster membership"` FollowListFrequency time.Duration `env:"ORLY_FOLLOW_LIST_FREQUENCY" usage:"how often to fetch admin follow lists (default: 1h)" default:"1h"` + // 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"` + FollowsThrottleMaxDelay time.Duration `env:"ORLY_FOLLOWS_THROTTLE_MAX" default:"60s" usage:"maximum throttle delay cap"` + // Blossom blob storage service level settings 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)"` @@ -841,3 +846,15 @@ func (cfg *C) GetNRCConfigValues() ( cfg.NRCUseCashu, sessionTimeout } + +// GetFollowsThrottleConfigValues returns the progressive throttle configuration values +// for the follows ACL mode. This allows non-followed users to write with increasing delay. +func (cfg *C) GetFollowsThrottleConfigValues() ( + enabled bool, + perEvent time.Duration, + maxDelay time.Duration, +) { + return cfg.FollowsThrottleEnabled, + cfg.FollowsThrottlePerEvent, + cfg.FollowsThrottleMaxDelay +} diff --git a/app/handle-event.go b/app/handle-event.go index cdc8ecf..d810aed 100644 --- a/app/handle-event.go +++ b/app/handle-event.go @@ -2,6 +2,7 @@ package app import ( "context" + "time" "lol.mleku.dev/chk" "lol.mleku.dev/log" @@ -254,6 +255,18 @@ 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 + } + } + // Route special event kinds (ephemeral, etc.) - use routing service if routeResult := l.eventRouter.Route(env.E, l.authedPubkey.Load()); routeResult.Action != routing.Continue { if routeResult.Action == routing.Handled { diff --git a/app/listener.go b/app/listener.go index ee5d32f..005a792 100644 --- a/app/listener.go +++ b/app/listener.go @@ -301,6 +301,22 @@ func (l *Listener) getManagedACL() *database.ManagedACL { return nil } +// getFollowsThrottleDelay returns the progressive throttle delay for follows ACL mode. +// Returns 0 if not in follows mode, throttle is disabled, or user is exempt. +func (l *Listener) getFollowsThrottleDelay(ev *event.E) time.Duration { + // Only applies to follows ACL mode + if acl.Registry.Active.Load() != "follows" { + return 0 + } + // Find the Follows ACL instance and get the throttle delay + for _, aclInstance := range acl.Registry.ACL { + if follows, ok := aclInstance.(*acl.Follows); ok { + return follows.GetThrottleDelay(ev.Pubkey, l.remote) + } + } + return 0 +} + // QueryEvents queries events using the database QueryEvents method func (l *Listener) QueryEvents(ctx context.Context, f *filter.F) (event.S, error) { return l.DB.QueryEvents(ctx, f) diff --git a/pkg/acl/follows.go b/pkg/acl/follows.go index 0df50fb..bfd7851 100644 --- a/pkg/acl/follows.go +++ b/pkg/acl/follows.go @@ -45,6 +45,8 @@ type Follows struct { lastFollowListFetch time.Time // Callback for external notification of follow list changes onFollowListUpdate func() + // Progressive throttle for non-followed users (nil if disabled) + throttle *ProgressiveThrottle } func (f *Follows) Configure(cfg ...any) (err error) { @@ -131,6 +133,22 @@ func (f *Follows) Configure(cfg ...any) (err error) { } } } + + // Initialize progressive throttle if enabled + if f.cfg.FollowsThrottleEnabled { + perEvent := f.cfg.FollowsThrottlePerEvent + if perEvent == 0 { + perEvent = 200 * time.Millisecond + } + maxDelay := f.cfg.FollowsThrottleMaxDelay + if maxDelay == 0 { + maxDelay = 60 * time.Second + } + f.throttle = NewProgressiveThrottle(perEvent, maxDelay) + log.I.F("follows ACL: progressive throttle enabled (increment: %v, max: %v)", + perEvent, maxDelay) + } + return } @@ -155,6 +173,10 @@ func (f *Follows) GetAccessLevel(pub []byte, address string) (level string) { if f.cfg == nil { return "write" } + // If throttle enabled, non-followed users get write access (with delay applied in handle-event) + if f.throttle != nil { + return "write" + } return "read" } @@ -165,6 +187,41 @@ func (f *Follows) GetACLInfo() (name, description, documentation string) { func (f *Follows) Type() string { return "follows" } +// GetThrottleDelay returns the progressive throttle delay for this event. +// Returns 0 if throttle is disabled or if the user is exempt (owner/admin/followed). +func (f *Follows) GetThrottleDelay(pubkey []byte, ip string) time.Duration { + if f.throttle == nil { + return 0 + } + + // Check if user is exempt from throttling + f.followsMx.RLock() + defer f.followsMx.RUnlock() + + // Owners bypass throttle + for _, v := range f.owners { + if utils.FastEqual(v, pubkey) { + return 0 + } + } + // Admins bypass throttle + for _, v := range f.admins { + if utils.FastEqual(v, pubkey) { + return 0 + } + } + // Followed users bypass throttle + for _, v := range f.follows { + if utils.FastEqual(v, pubkey) { + return 0 + } + } + + // Non-followed users get throttled + pubkeyHex := hex.EncodeToString(pubkey) + return f.throttle.GetDelay(ip, pubkeyHex) +} + func (f *Follows) adminRelays() (urls []string) { f.followsMx.RLock() admins := make([][]byte, len(f.admins)) @@ -353,6 +410,29 @@ func (f *Follows) Syncer() { // Start periodic follow list and metadata fetching go f.startPeriodicFollowListFetching() + + // Start throttle cleanup goroutine if throttle is enabled + if f.throttle != nil { + go f.throttleCleanup() + } +} + +// throttleCleanup periodically removes fully-decayed throttle entries +func (f *Follows) throttleCleanup() { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-f.Ctx.Done(): + return + case <-ticker.C: + f.throttle.Cleanup() + ipCount, pubkeyCount := f.throttle.Stats() + log.T.F("follows throttle: cleanup complete, tracking %d IPs and %d pubkeys", + ipCount, pubkeyCount) + } + } } // startPeriodicFollowListFetching starts periodic fetching of admin follow lists diff --git a/pkg/acl/follows_throttle.go b/pkg/acl/follows_throttle.go new file mode 100644 index 0000000..9cc2bd9 --- /dev/null +++ b/pkg/acl/follows_throttle.go @@ -0,0 +1,126 @@ +package acl + +import ( + "sync" + "time" +) + +// ThrottleState tracks accumulated delay for an identity (IP or pubkey) +type ThrottleState struct { + AccumulatedDelay time.Duration + LastEventTime time.Time +} + +// ProgressiveThrottle implements linear delay with time decay. +// Each event adds perEvent delay, and delay decays at 1:1 ratio with elapsed time. +// This creates a natural rate limit that averages to 1 event per perEvent interval. +type ProgressiveThrottle struct { + mu sync.Mutex + ipStates map[string]*ThrottleState + pubkeyStates map[string]*ThrottleState + perEvent time.Duration // delay increment per event (default 200ms) + maxDelay time.Duration // cap (default 60s) +} + +// NewProgressiveThrottle creates a new throttle with the given parameters. +// perEvent is the delay added per event (e.g., 200ms). +// maxDelay is the maximum accumulated delay cap (e.g., 60s). +func NewProgressiveThrottle(perEvent, maxDelay time.Duration) *ProgressiveThrottle { + return &ProgressiveThrottle{ + ipStates: make(map[string]*ThrottleState), + pubkeyStates: make(map[string]*ThrottleState), + perEvent: perEvent, + maxDelay: maxDelay, + } +} + +// GetDelay returns accumulated delay for this identity and updates state. +// It tracks both IP and pubkey independently and returns the maximum of both. +// This prevents evasion via different pubkeys from same IP or vice versa. +func (pt *ProgressiveThrottle) GetDelay(ip, pubkeyHex string) time.Duration { + pt.mu.Lock() + defer pt.mu.Unlock() + + now := time.Now() + var ipDelay, pubkeyDelay time.Duration + + if ip != "" { + ipDelay = pt.updateState(pt.ipStates, ip, now) + } + if pubkeyHex != "" { + pubkeyDelay = pt.updateState(pt.pubkeyStates, pubkeyHex, now) + } + + // Return max of both to prevent evasion + if ipDelay > pubkeyDelay { + return ipDelay + } + return pubkeyDelay +} + +// updateState calculates and updates the delay for a single identity. +// The algorithm: +// 1. Decay: subtract elapsed time from accumulated delay (1:1 ratio) +// 2. Add: add perEvent for this new event +// 3. Cap: limit to maxDelay +func (pt *ProgressiveThrottle) updateState(states map[string]*ThrottleState, key string, now time.Time) time.Duration { + state, exists := states[key] + if !exists { + // First event from this identity + states[key] = &ThrottleState{ + AccumulatedDelay: pt.perEvent, + LastEventTime: now, + } + return pt.perEvent + } + + // Decay: subtract elapsed time (1:1 ratio) + elapsed := now.Sub(state.LastEventTime) + state.AccumulatedDelay -= elapsed + if state.AccumulatedDelay < 0 { + state.AccumulatedDelay = 0 + } + + // Add new event's delay + state.AccumulatedDelay += pt.perEvent + state.LastEventTime = now + + // Cap at max + if state.AccumulatedDelay > pt.maxDelay { + state.AccumulatedDelay = pt.maxDelay + } + + return state.AccumulatedDelay +} + +// Cleanup removes entries that have fully decayed (no remaining delay). +// This should be called periodically to prevent unbounded memory growth. +func (pt *ProgressiveThrottle) Cleanup() { + pt.mu.Lock() + defer pt.mu.Unlock() + + now := time.Now() + + // Remove IP entries that have fully decayed + for k, v := range pt.ipStates { + elapsed := now.Sub(v.LastEventTime) + if elapsed >= v.AccumulatedDelay { + delete(pt.ipStates, k) + } + } + + // Remove pubkey entries that have fully decayed + for k, v := range pt.pubkeyStates { + elapsed := now.Sub(v.LastEventTime) + if elapsed >= v.AccumulatedDelay { + delete(pt.pubkeyStates, k) + } + } +} + +// Stats returns the current number of tracked IPs and pubkeys (for monitoring) +func (pt *ProgressiveThrottle) Stats() (ipCount, pubkeyCount int) { + pt.mu.Lock() + defer pt.mu.Unlock() + return len(pt.ipStates), len(pt.pubkeyStates) +} diff --git a/pkg/bbolt/save-event.go b/pkg/bbolt/save-event.go index c97c201..51acb66 100644 --- a/pkg/bbolt/save-event.go +++ b/pkg/bbolt/save-event.go @@ -350,12 +350,15 @@ func (r *bboltSerialResolver) GetPubkeyBySerial(serial uint64) (pubkey []byte, e r.b.db.View(func(tx *bolt.Tx) error { bucket := tx.Bucket(bucketSpk) if bucket == nil { + err = errors.New("bbolt: spk bucket not found") return nil } val := bucket.Get(makeSerialKey(serial)) if val != nil { pubkey = make([]byte, 32) copy(pubkey, val) + } else { + err = errors.New("bbolt: pubkey serial not found") } return nil }) @@ -374,12 +377,15 @@ func (r *bboltSerialResolver) GetEventIdBySerial(serial uint64) (eventID []byte, r.b.db.View(func(tx *bolt.Tx) error { bucket := tx.Bucket(bucketSei) if bucket == nil { + err = errors.New("bbolt: sei bucket not found") return nil } val := bucket.Get(makeSerialKey(serial)) if val != nil { eventID = make([]byte, 32) copy(eventID, val) + } else { + err = errors.New("bbolt: event serial not found") } return nil }) diff --git a/pkg/version/version b/pkg/version/version index b128c3c..7f37ed4 100644 --- a/pkg/version/version +++ b/pkg/version/version @@ -1 +1 @@ -v0.48.9 +v0.48.10