Add progressive throttle for follows ACL mode (v0.48.10)
Some checks failed
Go / build-and-release (push) Has been cancelled
Some checks failed
Go / build-and-release (push) Has been cancelled
- Add progressive throttle feature for follows ACL mode, allowing non-followed users to write with increasing delay instead of blocking - Delay increases linearly per event (default 200ms) and decays at 1:1 ratio with elapsed time, capping at configurable max (default 60s) - Track both IP and pubkey independently to prevent evasion - Add periodic cleanup to remove fully-decayed throttle entries - Fix BBolt serial resolver to return proper errors when buckets or entries are not found Files modified: - app/config/config.go: Add ORLY_FOLLOWS_THROTTLE_* env vars - app/handle-event.go: Apply throttle delay before event processing - app/listener.go: Add getFollowsThrottleDelay helper method - pkg/acl/follows.go: Integrate throttle with follows ACL - pkg/acl/follows_throttle.go: New progressive throttle implementation - pkg/bbolt/save-event.go: Return errors from serial lookups - pkg/version/version: Bump to v0.48.10 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,8 @@
|
|||||||
"allow": [],
|
"allow": [],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": [],
|
"ask": [],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": []
|
||||||
"/home/mleku/smesh",
|
|
||||||
"/home/mleku/Tourmaline",
|
|
||||||
"/home/mleku/Amber"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"outputStyle": "Default",
|
"outputStyle": "Default",
|
||||||
"MAX_THINKING_TOKENS": "8000"
|
"MAX_THINKING_TOKENS": "16000"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ type C struct {
|
|||||||
ClusterAdmins []string `env:"ORLY_CLUSTER_ADMINS" usage:"comma-separated list of npubs authorized to manage cluster membership"`
|
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"`
|
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
|
// 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)"`
|
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,
|
cfg.NRCUseCashu,
|
||||||
sessionTimeout
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"lol.mleku.dev/chk"
|
"lol.mleku.dev/chk"
|
||||||
"lol.mleku.dev/log"
|
"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)
|
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
|
// 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 := l.eventRouter.Route(env.E, l.authedPubkey.Load()); routeResult.Action != routing.Continue {
|
||||||
if routeResult.Action == routing.Handled {
|
if routeResult.Action == routing.Handled {
|
||||||
|
|||||||
@@ -301,6 +301,22 @@ func (l *Listener) getManagedACL() *database.ManagedACL {
|
|||||||
return nil
|
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
|
// QueryEvents queries events using the database QueryEvents method
|
||||||
func (l *Listener) QueryEvents(ctx context.Context, f *filter.F) (event.S, error) {
|
func (l *Listener) QueryEvents(ctx context.Context, f *filter.F) (event.S, error) {
|
||||||
return l.DB.QueryEvents(ctx, f)
|
return l.DB.QueryEvents(ctx, f)
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ type Follows struct {
|
|||||||
lastFollowListFetch time.Time
|
lastFollowListFetch time.Time
|
||||||
// Callback for external notification of follow list changes
|
// Callback for external notification of follow list changes
|
||||||
onFollowListUpdate func()
|
onFollowListUpdate func()
|
||||||
|
// Progressive throttle for non-followed users (nil if disabled)
|
||||||
|
throttle *ProgressiveThrottle
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Follows) Configure(cfg ...any) (err error) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +173,10 @@ func (f *Follows) GetAccessLevel(pub []byte, address string) (level string) {
|
|||||||
if f.cfg == nil {
|
if f.cfg == nil {
|
||||||
return "write"
|
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"
|
return "read"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +187,41 @@ func (f *Follows) GetACLInfo() (name, description, documentation string) {
|
|||||||
|
|
||||||
func (f *Follows) Type() string { return "follows" }
|
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) {
|
func (f *Follows) adminRelays() (urls []string) {
|
||||||
f.followsMx.RLock()
|
f.followsMx.RLock()
|
||||||
admins := make([][]byte, len(f.admins))
|
admins := make([][]byte, len(f.admins))
|
||||||
@@ -353,6 +410,29 @@ func (f *Follows) Syncer() {
|
|||||||
|
|
||||||
// Start periodic follow list and metadata fetching
|
// Start periodic follow list and metadata fetching
|
||||||
go f.startPeriodicFollowListFetching()
|
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
|
// startPeriodicFollowListFetching starts periodic fetching of admin follow lists
|
||||||
|
|||||||
126
pkg/acl/follows_throttle.go
Normal file
126
pkg/acl/follows_throttle.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -350,12 +350,15 @@ func (r *bboltSerialResolver) GetPubkeyBySerial(serial uint64) (pubkey []byte, e
|
|||||||
r.b.db.View(func(tx *bolt.Tx) error {
|
r.b.db.View(func(tx *bolt.Tx) error {
|
||||||
bucket := tx.Bucket(bucketSpk)
|
bucket := tx.Bucket(bucketSpk)
|
||||||
if bucket == nil {
|
if bucket == nil {
|
||||||
|
err = errors.New("bbolt: spk bucket not found")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
val := bucket.Get(makeSerialKey(serial))
|
val := bucket.Get(makeSerialKey(serial))
|
||||||
if val != nil {
|
if val != nil {
|
||||||
pubkey = make([]byte, 32)
|
pubkey = make([]byte, 32)
|
||||||
copy(pubkey, val)
|
copy(pubkey, val)
|
||||||
|
} else {
|
||||||
|
err = errors.New("bbolt: pubkey serial not found")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -374,12 +377,15 @@ func (r *bboltSerialResolver) GetEventIdBySerial(serial uint64) (eventID []byte,
|
|||||||
r.b.db.View(func(tx *bolt.Tx) error {
|
r.b.db.View(func(tx *bolt.Tx) error {
|
||||||
bucket := tx.Bucket(bucketSei)
|
bucket := tx.Bucket(bucketSei)
|
||||||
if bucket == nil {
|
if bucket == nil {
|
||||||
|
err = errors.New("bbolt: sei bucket not found")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
val := bucket.Get(makeSerialKey(serial))
|
val := bucket.Get(makeSerialKey(serial))
|
||||||
if val != nil {
|
if val != nil {
|
||||||
eventID = make([]byte, 32)
|
eventID = make([]byte, 32)
|
||||||
copy(eventID, val)
|
copy(eventID, val)
|
||||||
|
} else {
|
||||||
|
err = errors.New("bbolt: event serial not found")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
v0.48.9
|
v0.48.10
|
||||||
|
|||||||
Reference in New Issue
Block a user