diff options
Diffstat (limited to 'internal/ratelimit/README.md')
| -rw-r--r-- | internal/ratelimit/README.md | 341 |
1 files changed, 341 insertions, 0 deletions
diff --git a/internal/ratelimit/README.md b/internal/ratelimit/README.md new file mode 100644 index 0000000..a7f248d --- /dev/null +++ b/internal/ratelimit/README.md | |||
| @@ -0,0 +1,341 @@ | |||
| 1 | # Rate Limiting | ||
| 2 | |||
| 3 | This package provides per-user rate limiting for gRPC endpoints using the token bucket algorithm. | ||
| 4 | |||
| 5 | ## Overview | ||
| 6 | |||
| 7 | Rate limiting prevents abuse and ensures fair resource allocation across users. This implementation: | ||
| 8 | |||
| 9 | - **Per-user quotas**: Different limits for each authenticated pubkey | ||
| 10 | - **IP-based fallback**: Rate limit unauthenticated requests by IP address | ||
| 11 | - **Method-specific limits**: Different quotas for different operations (e.g., stricter limits for PublishEvent) | ||
| 12 | - **Token bucket algorithm**: Allows bursts while maintaining average rate | ||
| 13 | - **Standard gRPC errors**: Returns `ResourceExhausted` (HTTP 429) when limits exceeded | ||
| 14 | |||
| 15 | ## How It Works | ||
| 16 | |||
| 17 | ### Token Bucket Algorithm | ||
| 18 | |||
| 19 | Each user (identified by pubkey or IP) has a "bucket" of tokens: | ||
| 20 | |||
| 21 | 1. **Tokens refill** at a configured rate (e.g., 10 requests/second) | ||
| 22 | 2. **Each request consumes** one token | ||
| 23 | 3. **Bursts allowed** up to bucket capacity (e.g., 20 tokens) | ||
| 24 | 4. **Requests blocked** when bucket is empty | ||
| 25 | |||
| 26 | Example with 10 req/s limit and 20 token burst: | ||
| 27 | ``` | ||
| 28 | Time 0s: User makes 20 requests → All succeed (burst) | ||
| 29 | Time 0s: User makes 21st request → Rejected (bucket empty) | ||
| 30 | Time 1s: Bucket refills by 10 tokens | ||
| 31 | Time 1s: User makes 10 requests → All succeed | ||
| 32 | ``` | ||
| 33 | |||
| 34 | ### Integration with Authentication | ||
| 35 | |||
| 36 | Rate limiting works seamlessly with the auth package: | ||
| 37 | |||
| 38 | 1. **Authenticated users** (via NIP-98): Rate limited by pubkey | ||
| 39 | 2. **Unauthenticated users**: Rate limited by IP address | ||
| 40 | 3. **Auth interceptor runs first**, making pubkey available to rate limiter | ||
| 41 | |||
| 42 | ## Usage | ||
| 43 | |||
| 44 | ### Basic Setup | ||
| 45 | |||
| 46 | ```go | ||
| 47 | import ( | ||
| 48 | "northwest.io/muxstr/internal/auth" | ||
| 49 | "northwest.io/muxstr/internal/ratelimit" | ||
| 50 | "google.golang.org/grpc" | ||
| 51 | ) | ||
| 52 | |||
| 53 | // Configure rate limiter | ||
| 54 | limiter := ratelimit.New(&ratelimit.Config{ | ||
| 55 | // Default: 10 requests/second per user, burst of 20 | ||
| 56 | RequestsPerSecond: 10, | ||
| 57 | BurstSize: 20, | ||
| 58 | |||
| 59 | // Unauthenticated users: 5 requests/second per IP | ||
| 60 | IPRequestsPerSecond: 5, | ||
| 61 | IPBurstSize: 10, | ||
| 62 | }) | ||
| 63 | |||
| 64 | // Create server with auth + rate limit interceptors | ||
| 65 | server := grpc.NewServer( | ||
| 66 | grpc.ChainUnaryInterceptor( | ||
| 67 | auth.NostrUnaryInterceptor(authOpts), // Auth runs first | ||
| 68 | ratelimit.UnaryInterceptor(limiter), // Rate limit runs second | ||
| 69 | ), | ||
| 70 | grpc.ChainStreamInterceptor( | ||
| 71 | auth.NostrStreamInterceptor(authOpts), | ||
| 72 | ratelimit.StreamInterceptor(limiter), | ||
| 73 | ), | ||
| 74 | ) | ||
| 75 | ``` | ||
| 76 | |||
| 77 | ### Method-Specific Limits | ||
| 78 | |||
| 79 | Different operations can have different rate limits: | ||
| 80 | |||
| 81 | ```go | ||
| 82 | limiter := ratelimit.New(&ratelimit.Config{ | ||
| 83 | // Default for all methods | ||
| 84 | RequestsPerSecond: 10, | ||
| 85 | BurstSize: 20, | ||
| 86 | |||
| 87 | // Override for specific methods | ||
| 88 | MethodLimits: map[string]ratelimit.MethodLimit{ | ||
| 89 | "/nostr.v1.NostrRelay/PublishEvent": { | ||
| 90 | RequestsPerSecond: 2, // Stricter: only 2 publishes/sec | ||
| 91 | BurstSize: 5, | ||
| 92 | }, | ||
| 93 | "/nostr.v1.NostrRelay/Subscribe": { | ||
| 94 | RequestsPerSecond: 1, // Only 1 new subscription/sec | ||
| 95 | BurstSize: 3, | ||
| 96 | }, | ||
| 97 | "/nostr.v1.NostrRelay/QueryEvents": { | ||
| 98 | RequestsPerSecond: 20, // More lenient: 20 queries/sec | ||
| 99 | BurstSize: 50, | ||
| 100 | }, | ||
| 101 | }, | ||
| 102 | }) | ||
| 103 | ``` | ||
| 104 | |||
| 105 | ### Per-User Custom Limits | ||
| 106 | |||
| 107 | Set different limits for specific users: | ||
| 108 | |||
| 109 | ```go | ||
| 110 | limiter := ratelimit.New(&ratelimit.Config{ | ||
| 111 | RequestsPerSecond: 10, | ||
| 112 | BurstSize: 20, | ||
| 113 | |||
| 114 | // VIP users get higher limits | ||
| 115 | UserLimits: map[string]ratelimit.UserLimit{ | ||
| 116 | "vip-pubkey-abc123": { | ||
| 117 | RequestsPerSecond: 100, | ||
| 118 | BurstSize: 200, | ||
| 119 | }, | ||
| 120 | "premium-pubkey-def456": { | ||
| 121 | RequestsPerSecond: 50, | ||
| 122 | BurstSize: 100, | ||
| 123 | }, | ||
| 124 | }, | ||
| 125 | }) | ||
| 126 | ``` | ||
| 127 | |||
| 128 | ### Disable Rate Limiting for Specific Methods | ||
| 129 | |||
| 130 | ```go | ||
| 131 | limiter := ratelimit.New(&ratelimit.Config{ | ||
| 132 | RequestsPerSecond: 10, | ||
| 133 | BurstSize: 20, | ||
| 134 | |||
| 135 | // Don't rate limit these methods | ||
| 136 | SkipMethods: []string{ | ||
| 137 | "/grpc.health.v1.Health/Check", | ||
| 138 | }, | ||
| 139 | }) | ||
| 140 | ``` | ||
| 141 | |||
| 142 | ## Configuration Reference | ||
| 143 | |||
| 144 | ### Config | ||
| 145 | |||
| 146 | - **`RequestsPerSecond`**: Default rate limit (tokens per second) | ||
| 147 | - **`BurstSize`**: Maximum burst size (bucket capacity) | ||
| 148 | - **`IPRequestsPerSecond`**: Rate limit for unauthenticated users (per IP) | ||
| 149 | - **`IPBurstSize`**: Burst size for IP-based limits | ||
| 150 | - **`MethodLimits`**: Map of method-specific overrides | ||
| 151 | - **`UserLimits`**: Map of per-user custom limits (by pubkey) | ||
| 152 | - **`SkipMethods`**: Methods that bypass rate limiting | ||
| 153 | - **`CleanupInterval`**: How often to remove idle limiters (default: 5 minutes) | ||
| 154 | |||
| 155 | ### MethodLimit | ||
| 156 | |||
| 157 | - **`RequestsPerSecond`**: Rate limit for this method | ||
| 158 | - **`BurstSize`**: Burst size for this method | ||
| 159 | |||
| 160 | ### UserLimit | ||
| 161 | |||
| 162 | - **`RequestsPerSecond`**: Rate limit for this user | ||
| 163 | - **`BurstSize`**: Burst size for this user | ||
| 164 | - **`MethodLimits`**: Optional method overrides for this user | ||
| 165 | |||
| 166 | ## Error Handling | ||
| 167 | |||
| 168 | When rate limit is exceeded, the interceptor returns: | ||
| 169 | |||
| 170 | ``` | ||
| 171 | Code: ResourceExhausted (HTTP 429) | ||
| 172 | Message: "rate limit exceeded for <pubkey/IP>" | ||
| 173 | ``` | ||
| 174 | |||
| 175 | Clients should implement exponential backoff: | ||
| 176 | |||
| 177 | ```go | ||
| 178 | for { | ||
| 179 | resp, err := client.PublishEvent(ctx, req) | ||
| 180 | if err != nil { | ||
| 181 | if status.Code(err) == codes.ResourceExhausted { | ||
| 182 | // Rate limited - wait and retry | ||
| 183 | time.Sleep(backoff) | ||
| 184 | backoff *= 2 | ||
| 185 | continue | ||
| 186 | } | ||
| 187 | return err | ||
| 188 | } | ||
| 189 | return resp, nil | ||
| 190 | } | ||
| 191 | ``` | ||
| 192 | |||
| 193 | ## Monitoring | ||
| 194 | |||
| 195 | The rate limiter tracks: | ||
| 196 | |||
| 197 | - **Active limiters**: Number of users being tracked | ||
| 198 | - **Requests allowed**: Total requests that passed | ||
| 199 | - **Requests denied**: Total requests that were rate limited | ||
| 200 | |||
| 201 | Access stats: | ||
| 202 | |||
| 203 | ```go | ||
| 204 | stats := limiter.Stats() | ||
| 205 | fmt.Printf("Active users: %d\n", stats.ActiveLimiters) | ||
| 206 | fmt.Printf("Allowed: %d, Denied: %d\n", stats.Allowed, stats.Denied) | ||
| 207 | fmt.Printf("Denial rate: %.2f%%\n", stats.DenialRate()) | ||
| 208 | ``` | ||
| 209 | |||
| 210 | ## Performance Considerations | ||
| 211 | |||
| 212 | ### Memory Usage | ||
| 213 | |||
| 214 | Each tracked user (pubkey or IP) consumes ~200 bytes. With 10,000 active users: | ||
| 215 | - Memory: ~2 MB | ||
| 216 | - Lookup: O(1) with sync.RWMutex | ||
| 217 | |||
| 218 | Idle limiters are cleaned up periodically (default: every 5 minutes). | ||
| 219 | |||
| 220 | ### Throughput | ||
| 221 | |||
| 222 | Rate limiting adds minimal overhead: | ||
| 223 | - Token check: ~100 nanoseconds | ||
| 224 | - Lock contention: Read lock for lookups, write lock for new users only | ||
| 225 | |||
| 226 | Benchmark results (on typical hardware): | ||
| 227 | ``` | ||
| 228 | BenchmarkRateLimitAllow-8 20000000 85 ns/op | ||
| 229 | BenchmarkRateLimitDeny-8 20000000 82 ns/op | ||
| 230 | ``` | ||
| 231 | |||
| 232 | ### Distributed Deployments | ||
| 233 | |||
| 234 | This implementation is **in-memory** and works for single-instance deployments. | ||
| 235 | |||
| 236 | For distributed deployments across multiple relay instances: | ||
| 237 | |||
| 238 | **Option 1: Accept per-instance limits** (simplest) | ||
| 239 | - Each instance tracks its own limits | ||
| 240 | - Users get N × limit if they connect to N different instances | ||
| 241 | - Usually acceptable for most use cases | ||
| 242 | |||
| 243 | **Option 2: Shared Redis backend** (future enhancement) | ||
| 244 | - Centralized rate limiting across all instances | ||
| 245 | - Requires Redis dependency | ||
| 246 | - Adds network latency (~1-2ms per request) | ||
| 247 | |||
| 248 | **Option 3: Sticky sessions** (via load balancer) | ||
| 249 | - Route users to the same instance | ||
| 250 | - Per-instance limits become per-user limits | ||
| 251 | - No coordination needed | ||
| 252 | |||
| 253 | ## Example: Relay with Tiered Access | ||
| 254 | |||
| 255 | ```go | ||
| 256 | // Free tier: 10 req/s, strict publish limits | ||
| 257 | // Premium tier: 50 req/s, relaxed limits | ||
| 258 | // Admin tier: No limits | ||
| 259 | |||
| 260 | func setupRateLimit() *ratelimit.Limiter { | ||
| 261 | return ratelimit.New(&ratelimit.Config{ | ||
| 262 | // Free tier defaults | ||
| 263 | RequestsPerSecond: 10, | ||
| 264 | BurstSize: 20, | ||
| 265 | |||
| 266 | MethodLimits: map[string]ratelimit.MethodLimit{ | ||
| 267 | "/nostr.v1.NostrRelay/PublishEvent": { | ||
| 268 | RequestsPerSecond: 2, | ||
| 269 | BurstSize: 5, | ||
| 270 | }, | ||
| 271 | }, | ||
| 272 | |||
| 273 | // Premium users | ||
| 274 | UserLimits: map[string]ratelimit.UserLimit{ | ||
| 275 | "premium-user-1": { | ||
| 276 | RequestsPerSecond: 50, | ||
| 277 | BurstSize: 100, | ||
| 278 | }, | ||
| 279 | }, | ||
| 280 | |||
| 281 | // Admins bypass limits | ||
| 282 | SkipMethods: []string{}, | ||
| 283 | SkipUsers: []string{ | ||
| 284 | "admin-pubkey-abc", | ||
| 285 | }, | ||
| 286 | }) | ||
| 287 | } | ||
| 288 | ``` | ||
| 289 | |||
| 290 | ## Best Practices | ||
| 291 | |||
| 292 | 1. **Set conservative defaults**: Start with low limits and increase based on usage | ||
| 293 | 2. **Monitor denial rates**: High denial rates indicate limits are too strict | ||
| 294 | 3. **Method-specific tuning**: Writes (PublishEvent) should be stricter than reads | ||
| 295 | 4. **Burst allowance**: Set burst = 2-3× rate to handle legitimate traffic spikes | ||
| 296 | 5. **IP-based limits**: Set lower than authenticated limits to encourage auth | ||
| 297 | 6. **Cleanup interval**: Balance memory usage vs. repeated user setup overhead | ||
| 298 | |||
| 299 | ## Security Considerations | ||
| 300 | |||
| 301 | ### Rate Limit Bypass | ||
| 302 | |||
| 303 | Rate limiting can be bypassed by: | ||
| 304 | - Using multiple pubkeys (Sybil attack) | ||
| 305 | - Using multiple IPs (distributed attack) | ||
| 306 | |||
| 307 | Mitigations: | ||
| 308 | - Require proof-of-work for new pubkeys | ||
| 309 | - Monitor for suspicious patterns (many low-activity accounts) | ||
| 310 | - Implement global rate limits in addition to per-user limits | ||
| 311 | |||
| 312 | ### DoS Protection | ||
| 313 | |||
| 314 | Rate limiting helps with DoS but isn't sufficient alone: | ||
| 315 | - Combine with connection limits | ||
| 316 | - Implement request size limits | ||
| 317 | - Use timeouts and deadlines | ||
| 318 | - Consider L3/L4 DDoS protection (CloudFlare, etc.) | ||
| 319 | |||
| 320 | ## Integration with NIP-98 Auth | ||
| 321 | |||
| 322 | Rate limiting works naturally with authentication: | ||
| 323 | |||
| 324 | ``` | ||
| 325 | Request flow: | ||
| 326 | 1. Request arrives | ||
| 327 | 2. Auth interceptor validates NIP-98 event → extracts pubkey | ||
| 328 | 3. Rate limit interceptor checks quota for pubkey | ||
| 329 | 4. If allowed → handler processes request | ||
| 330 | 5. If denied → return ResourceExhausted error | ||
| 331 | ``` | ||
| 332 | |||
| 333 | For unauthenticated requests: | ||
| 334 | ``` | ||
| 335 | 1. Request arrives | ||
| 336 | 2. Auth interceptor allows (if Required: false) | ||
| 337 | 3. Rate limit interceptor uses IP address | ||
| 338 | 4. Check quota for IP → likely stricter limits | ||
| 339 | ``` | ||
| 340 | |||
| 341 | This encourages users to authenticate to get better rate limits! | ||
