-
Notifications
You must be signed in to change notification settings - Fork 878
feat(ratelimiter): add RateLimiterRegistry + evmrpc config fields #3507
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
amir-deris
wants to merge
17
commits into
main
Choose a base branch
from
amir/plt-411-rate-limiter-registry-for-rpc
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
18c6c2a
Added rate limiter registry and test
amir-deris cef47db
fixed config_test
amir-deris 0047d61
Merge branch 'main' into amir/plt-411-rate-limiter-registry-for-rpc
amir-deris b70a160
Removed method name attribute to bound cardinality of metric
amir-deris ecfe1cc
Updated clinet ip extraction algorithm
amir-deris acf81f7
Fixed config format
amir-deris 305f418
moved metrics to their own file
amir-deris 9ca0c22
validted the ip string before rate limiting it
amir-deris c70ba03
updated tests
amir-deris 9f05350
Fixing go fmt
amir-deris b942624
Added fix for multiple XFF header values
amir-deris e28bc73
Handled negative RPS config
amir-deris 733097d
removed evmrpc changes
amir-deris cff02e5
Fixed fmt
amir-deris fe5d3c9
added guard for burst = 0 to allow all traffic
amir-deris 4756e17
Updated TTL for active ips
amir-deris 7c3f6c5
Merge branch 'main' into amir/plt-411-rate-limiter-registry-for-rpc
amir-deris File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package ratelimiter | ||
|
|
||
| import ( | ||
| "go.opentelemetry.io/otel" | ||
| "go.opentelemetry.io/otel/metric" | ||
| ) | ||
|
|
||
| var ( | ||
| registryMeter = otel.Meter("ratelimiter") | ||
|
|
||
| registryMetrics = struct { | ||
| rejectedCounter metric.Int64Counter | ||
| }{ | ||
| rejectedCounter: must(registryMeter.Int64Counter( | ||
| "rpc_rate_limit_rejected_total", | ||
| metric.WithDescription("Total RPC requests rejected by the per-IP rate limiter"), | ||
| metric.WithUnit("{request}"), | ||
| )), | ||
| } | ||
| ) | ||
|
|
||
| func must[V any](v V, err error) V { | ||
| if err != nil { | ||
| panic(err) | ||
| } | ||
| return v | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,197 @@ | ||
| package ratelimiter | ||
|
|
||
| import ( | ||
| "context" | ||
| "net" | ||
| "net/http" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/hashicorp/golang-lru/v2/expirable" | ||
| "go.opentelemetry.io/otel/attribute" | ||
| "go.opentelemetry.io/otel/metric" | ||
| "golang.org/x/time/rate" | ||
| "google.golang.org/grpc/metadata" | ||
| "google.golang.org/grpc/peer" | ||
| ) | ||
|
|
||
| const ( | ||
| DefaultRPS = 200.0 | ||
| DefaultBurst = 400 | ||
|
|
||
| // lruSize bounds memory to ~8 MB at 50k entries (~160 bytes each). | ||
| lruSize = 50_000 | ||
| // lruTTL evicts IP entries that have been idle for 1 hour. | ||
| lruTTL = time.Hour | ||
| ) | ||
|
|
||
| // DefaultTrustedProxyCIDRs contains RFC-1918 ranges and loopback addresses. | ||
| // Requests arriving from these CIDRs are trusted to supply a valid X-Forwarded-For header. | ||
| var DefaultTrustedProxyCIDRs = []string{ | ||
| "127.0.0.0/8", | ||
| "::1/128", | ||
| "10.0.0.0/8", | ||
| "172.16.0.0/12", | ||
| "192.168.0.0/16", | ||
| "fc00::/7", | ||
| } | ||
|
|
||
| // Config holds the configuration for a Registry | ||
| type Config struct { | ||
| // Enabled is a temporary rollout gate (Phase 1 only). False passes all requests through. | ||
| Enabled bool | ||
| // RPS is the sustained request rate allowed per IP in requests/second. | ||
| // Zero disables per-IP rate limiting (all requests pass). | ||
| RPS float64 | ||
| // Burst is the maximum number of requests allowed in a single burst. | ||
| // Zero disables per-IP rate limiting (all requests pass). | ||
| Burst int | ||
| // TrustedProxyCIDRs lists CIDRs whose X-Forwarded-For headers are trusted. | ||
| // Empty means trust no proxy; use RemoteAddr / peer address directly. | ||
| TrustedProxyCIDRs []string | ||
| } | ||
|
|
||
| var DefaultConfig = Config{ | ||
| Enabled: true, | ||
| RPS: DefaultRPS, | ||
| Burst: DefaultBurst, | ||
| TrustedProxyCIDRs: DefaultTrustedProxyCIDRs, | ||
| } | ||
|
|
||
| // Registry is a per-IP token-bucket rate limiter backed by an expirable LRU. | ||
| // It is safe for concurrent use. | ||
| type Registry struct { | ||
| cfg Config | ||
| trustedProxies []*net.IPNet | ||
| lru *expirable.LRU[string, *rate.Limiter] | ||
| } | ||
|
|
||
| // New creates a Registry from cfg. Invalid CIDRs in TrustedProxyCIDRs are silently skipped. | ||
| func New(cfg Config) *Registry { | ||
| return &Registry{ | ||
| cfg: cfg, | ||
| trustedProxies: parseCIDRs(cfg.TrustedProxyCIDRs), | ||
| lru: expirable.NewLRU[string, *rate.Limiter](lruSize, nil, lruTTL), | ||
| } | ||
| } | ||
|
|
||
| // Allow reports whether the request from ip should be allowed for the given plane. | ||
| // Rejections increment rpc_rate_limit_rejected_total{plane}. | ||
| func (r *Registry) Allow(ctx context.Context, ip, plane string) bool { | ||
| if !r.cfg.Enabled || r.cfg.RPS <= 0 || r.cfg.Burst <= 0 { | ||
| return true | ||
| } | ||
| if r.getOrCreate(ip).Allow() { | ||
| return true | ||
| } | ||
| registryMetrics.rejectedCounter.Add( | ||
| ctx, | ||
| 1, | ||
| metric.WithAttributes( | ||
| attribute.String("plane", plane), | ||
| ), | ||
| ) | ||
|
amir-deris marked this conversation as resolved.
|
||
| return false | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| // IPFromHTTPRequest extracts the client IP from an HTTP request. | ||
| // If RemoteAddr belongs to a trusted proxy CIDR, the rightmost untrusted X-Forwarded-For | ||
| // entry is used. Walking right-to-left and skipping trusted CIDRs prevents a client from | ||
| // spoofing their IP by pre-setting X-Forwarded-For before the request reaches the proxy. | ||
| func (r *Registry) IPFromHTTPRequest(req *http.Request) string { | ||
| remoteIP := stripPort(req.RemoteAddr) | ||
| if r.isTrustedProxy(remoteIP) { | ||
| if xff := strings.Join(req.Header.Values("X-Forwarded-For"), ", "); xff != "" { | ||
| if ip := r.rightmostUntrustedIP(xff); ip != "" { | ||
| return ip | ||
| } | ||
| } | ||
| } | ||
| return remoteIP | ||
| } | ||
|
amir-deris marked this conversation as resolved.
|
||
|
|
||
| // IPFromGRPCContext extracts the client IP from a gRPC request context. | ||
| // If the transport peer belongs to a trusted proxy CIDR, the rightmost untrusted | ||
| // x-forwarded-for metadata entry is used. | ||
| func (r *Registry) IPFromGRPCContext(ctx context.Context) string { | ||
| peerIP := grpcPeerIP(ctx) | ||
| if peerIP != "" && r.isTrustedProxy(peerIP) { | ||
| if md, ok := metadata.FromIncomingContext(ctx); ok { | ||
| if vals := md.Get("x-forwarded-for"); len(vals) > 0 { | ||
| if ip := r.rightmostUntrustedIP(strings.Join(vals, ", ")); ip != "" { | ||
| return ip | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return peerIP | ||
| } | ||
|
|
||
| // rightmostUntrustedIP walks the comma-separated XFF list from right to left and returns | ||
| // the first IP that is not in TrustedProxyCIDRs. This is the real client IP: proxies | ||
| // append their view of the source address, so the rightmost untrusted entry cannot be | ||
| // forged by the client. | ||
| func (r *Registry) rightmostUntrustedIP(xff string) string { | ||
| parts := strings.Split(xff, ",") | ||
| for i := len(parts) - 1; i >= 0; i-- { | ||
| candidate := strings.TrimSpace(parts[i]) | ||
| if net.ParseIP(candidate) == nil { | ||
| continue | ||
| } | ||
| if !r.isTrustedProxy(candidate) { | ||
| return candidate | ||
| } | ||
| } | ||
| return "" | ||
| } | ||
|
amir-deris marked this conversation as resolved.
|
||
|
|
||
| // getOrCreate returns the existing limiter for ip or creates a fresh one. | ||
| // Add is called on every hit to refresh the TTL, ensuring only truly idle IPs expire. | ||
| func (r *Registry) getOrCreate(ip string) *rate.Limiter { | ||
| if l, ok := r.lru.Get(ip); ok { | ||
| r.lru.Add(ip, l) | ||
| return l | ||
| } | ||
| l := rate.NewLimiter(rate.Limit(r.cfg.RPS), r.cfg.Burst) | ||
| r.lru.Add(ip, l) | ||
| return l | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| func (r *Registry) isTrustedProxy(ip string) bool { | ||
| parsed := net.ParseIP(ip) | ||
| if parsed == nil { | ||
| return false | ||
| } | ||
| for _, n := range r.trustedProxies { | ||
| if n.Contains(parsed) { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| func parseCIDRs(cidrs []string) []*net.IPNet { | ||
| out := make([]*net.IPNet, 0, len(cidrs)) | ||
| for _, cidr := range cidrs { | ||
| if _, network, err := net.ParseCIDR(cidr); err == nil { | ||
| out = append(out, network) | ||
| } | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| func stripPort(addr string) string { | ||
| host, _, err := net.SplitHostPort(addr) | ||
| if err != nil { | ||
| return addr | ||
| } | ||
| return host | ||
| } | ||
|
|
||
| func grpcPeerIP(ctx context.Context) string { | ||
| p, ok := peer.FromContext(ctx) | ||
| if !ok || p.Addr == nil { | ||
| return "" | ||
| } | ||
| return stripPort(p.Addr.String()) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.