itsi-scheduler 0.1.11 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CODE_OF_CONDUCT.md +7 -0
- data/Cargo.lock +75 -14
- data/README.md +5 -0
- data/_index.md +7 -0
- data/ext/itsi_error/src/lib.rs +9 -0
- data/ext/itsi_error/target/debug/build/clang-sys-da71b0344e568175/out/common.rs +355 -0
- data/ext/itsi_error/target/debug/build/clang-sys-da71b0344e568175/out/dynamic.rs +276 -0
- data/ext/itsi_error/target/debug/build/clang-sys-da71b0344e568175/out/macros.rs +49 -0
- data/ext/itsi_error/target/debug/build/rb-sys-49f554618693db24/out/bindings-0.9.110-mri-arm64-darwin23-3.4.2.rs +8865 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-1mmt5sux7jb0i/s-h510z7m8v9-0bxu7yd.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-2vn3jey74oiw0/s-h5113n0e7e-1v5qzs6.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-37uv9dicz7awp/s-h510ykifhe-0tbnep2.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-37uv9dicz7awp/s-h510yyocpj-0tz7ug7.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-37uv9dicz7awp/s-h510z0xc8g-14ol18k.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-3g5qf4y7d54uj/s-h5113n0e7d-1trk8on.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-3lpfftm45d3e2/s-h510z7m8r3-1pxp20o.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-3o4qownhl3d7n/s-h510ykifek-1uxasnk.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-3o4qownhl3d7n/s-h510yyocki-11u37qm.lock +0 -0
- data/ext/itsi_error/target/debug/incremental/itsi_error-3o4qownhl3d7n/s-h510z0xc93-0pmy0zm.lock +0 -0
- data/ext/itsi_rb_helpers/Cargo.toml +1 -0
- data/ext/itsi_rb_helpers/src/heap_value.rs +18 -0
- data/ext/itsi_rb_helpers/src/lib.rs +34 -7
- data/ext/itsi_rb_helpers/target/debug/build/clang-sys-da71b0344e568175/out/common.rs +355 -0
- data/ext/itsi_rb_helpers/target/debug/build/clang-sys-da71b0344e568175/out/dynamic.rs +276 -0
- data/ext/itsi_rb_helpers/target/debug/build/clang-sys-da71b0344e568175/out/macros.rs +49 -0
- data/ext/itsi_rb_helpers/target/debug/build/rb-sys-eb9ed4ff3a60f995/out/bindings-0.9.110-mri-arm64-darwin23-3.4.2.rs +8865 -0
- data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-040pxg6yhb3g3/s-h5113n7a1b-03bwlt4.lock +0 -0
- data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-131g1u4dzkt1a/s-h51113xnh3-1eik1ip.lock +0 -0
- data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-131g1u4dzkt1a/s-h5111704jj-0g4rj8x.lock +0 -0
- data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-1q2d3drtxrzs5/s-h5113n79yl-0bxcqc5.lock +0 -0
- data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-374a9h7ovycj0/s-h51113xoox-10de2hp.lock +0 -0
- data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-374a9h7ovycj0/s-h5111704w7-0vdq7gq.lock +0 -0
- data/ext/itsi_server/Cargo.toml +69 -30
- data/ext/itsi_server/src/lib.rs +79 -147
- data/ext/itsi_server/src/{body_proxy → ruby_types/itsi_body_proxy}/big_bytes.rs +10 -5
- data/ext/itsi_server/src/{body_proxy/itsi_body_proxy.rs → ruby_types/itsi_body_proxy/mod.rs} +22 -3
- data/ext/itsi_server/src/ruby_types/itsi_grpc_request.rs +147 -0
- data/ext/itsi_server/src/ruby_types/itsi_grpc_response.rs +19 -0
- data/ext/itsi_server/src/ruby_types/itsi_grpc_stream/mod.rs +216 -0
- data/ext/itsi_server/src/{request/itsi_request.rs → ruby_types/itsi_http_request.rs} +101 -117
- data/ext/itsi_server/src/{response/itsi_response.rs → ruby_types/itsi_http_response.rs} +72 -41
- data/ext/itsi_server/src/ruby_types/itsi_server/file_watcher.rs +225 -0
- data/ext/itsi_server/src/ruby_types/itsi_server/itsi_server_config.rs +355 -0
- data/ext/itsi_server/src/ruby_types/itsi_server.rs +82 -0
- data/ext/itsi_server/src/ruby_types/mod.rs +55 -0
- data/ext/itsi_server/src/server/bind.rs +13 -5
- data/ext/itsi_server/src/server/byte_frame.rs +32 -0
- data/ext/itsi_server/src/server/cache_store.rs +74 -0
- data/ext/itsi_server/src/server/itsi_service.rs +172 -0
- data/ext/itsi_server/src/server/lifecycle_event.rs +3 -0
- data/ext/itsi_server/src/server/listener.rs +102 -2
- data/ext/itsi_server/src/server/middleware_stack/middleware.rs +153 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/allow_list.rs +47 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/auth_api_key.rs +58 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/auth_basic.rs +82 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/auth_jwt.rs +321 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/cache_control.rs +139 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/compression.rs +300 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/cors.rs +287 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/deny_list.rs +48 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/error_response.rs +127 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/etag.rs +191 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/grpc_service.rs +72 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/header_interpretation.rs +85 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/intrusion_protection.rs +195 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/log_requests.rs +82 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/mod.rs +82 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/proxy.rs +216 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/rate_limit.rs +124 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/redirect.rs +76 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/request_headers.rs +43 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/response_headers.rs +34 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/ruby_app.rs +93 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/static_assets.rs +162 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/string_rewrite.rs +158 -0
- data/ext/itsi_server/src/server/middleware_stack/middlewares/token_source.rs +12 -0
- data/ext/itsi_server/src/server/middleware_stack/mod.rs +315 -0
- data/ext/itsi_server/src/server/mod.rs +8 -1
- data/ext/itsi_server/src/server/process_worker.rs +38 -12
- data/ext/itsi_server/src/server/rate_limiter.rs +565 -0
- data/ext/itsi_server/src/server/request_job.rs +11 -0
- data/ext/itsi_server/src/server/serve_strategy/cluster_mode.rs +119 -42
- data/ext/itsi_server/src/server/serve_strategy/mod.rs +9 -6
- data/ext/itsi_server/src/server/serve_strategy/single_mode.rs +256 -111
- data/ext/itsi_server/src/server/signal.rs +19 -0
- data/ext/itsi_server/src/server/static_file_server.rs +984 -0
- data/ext/itsi_server/src/server/thread_worker.rs +139 -94
- data/ext/itsi_server/src/server/types.rs +43 -0
- data/ext/itsi_tracing/Cargo.toml +1 -0
- data/ext/itsi_tracing/src/lib.rs +216 -45
- data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-0994n8rpvvt9m/s-h510hfz1f6-1kbycmq.lock +0 -0
- data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-0bob7bf4yq34i/s-h5113125h5-0lh4rag.lock +0 -0
- data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2fcodulrxbbxo/s-h510h2infk-0hp5kjw.lock +0 -0
- data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2iak63r1woi1l/s-h510h2in4q-0kxfzw1.lock +0 -0
- data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2kk4qj9gn5dg2/s-h5113124kv-0enwon2.lock +0 -0
- data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2mwo0yas7dtw4/s-h510hfz1ha-1udgpei.lock +0 -0
- data/lib/itsi/scheduler/version.rb +1 -1
- data/lib/itsi/scheduler.rb +2 -2
- metadata +77 -12
- data/ext/itsi_server/extconf.rb +0 -6
- data/ext/itsi_server/src/body_proxy/mod.rs +0 -2
- data/ext/itsi_server/src/request/mod.rs +0 -1
- data/ext/itsi_server/src/response/mod.rs +0 -1
- data/ext/itsi_server/src/server/itsi_server.rs +0 -288
@@ -0,0 +1,565 @@
|
|
1
|
+
use async_trait::async_trait;
|
2
|
+
use rand::Rng;
|
3
|
+
use redis::aio::ConnectionManager;
|
4
|
+
use redis::{Client, RedisError, Script};
|
5
|
+
use serde::Deserialize;
|
6
|
+
use std::any::Any;
|
7
|
+
use std::collections::{HashMap, HashSet};
|
8
|
+
use std::sync::{Arc, LazyLock, Mutex};
|
9
|
+
use std::time::{Duration, Instant};
|
10
|
+
use tokio::sync::{Mutex as AsyncMutex, RwLock};
|
11
|
+
use tokio::time::timeout;
|
12
|
+
use url::Url;
|
13
|
+
|
14
|
+
#[derive(Debug)]
|
15
|
+
pub enum RateLimitError {
|
16
|
+
RedisError(RedisError),
|
17
|
+
RateLimitExceeded { limit: u64, count: u64 },
|
18
|
+
LockError,
|
19
|
+
ConnectionTimeout,
|
20
|
+
// Other error variants as needed.
|
21
|
+
}
|
22
|
+
|
23
|
+
impl From<RedisError> for RateLimitError {
|
24
|
+
fn from(err: RedisError) -> Self {
|
25
|
+
RateLimitError::RedisError(err)
|
26
|
+
}
|
27
|
+
}
|
28
|
+
|
29
|
+
impl std::fmt::Display for RateLimitError {
|
30
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
31
|
+
match self {
|
32
|
+
RateLimitError::RedisError(e) => write!(f, "Redis error: {}", e),
|
33
|
+
RateLimitError::RateLimitExceeded { limit, count } => {
|
34
|
+
write!(f, "Rate limit exceeded: {}/{}", count, limit)
|
35
|
+
}
|
36
|
+
RateLimitError::LockError => write!(f, "Failed to acquire lock"),
|
37
|
+
RateLimitError::ConnectionTimeout => write!(f, "Connection timeout"),
|
38
|
+
}
|
39
|
+
}
|
40
|
+
}
|
41
|
+
|
42
|
+
/// A RateLimiter trait for limiting HTTP requests
|
43
|
+
#[async_trait]
|
44
|
+
pub trait RateLimiter: Send + Sync + std::fmt::Debug {
|
45
|
+
/// Increments the counter associated with `key` and sets its expiration.
|
46
|
+
/// Returns the new counter value.
|
47
|
+
///
|
48
|
+
/// If the operation fails, returns Ok(0) to fail open.
|
49
|
+
async fn increment(&self, key: &str, timeout: Duration) -> Result<u64, RateLimitError>;
|
50
|
+
|
51
|
+
/// Checks if the rate limit is exceeded for the given key.
|
52
|
+
/// Returns Ok(current_count) if not exceeded, or Err(RateLimitExceeded) if exceeded.
|
53
|
+
///
|
54
|
+
/// If there's an error (like connectivity issues), this will always return Ok
|
55
|
+
/// to allow the request through (fail open).
|
56
|
+
async fn check_limit(
|
57
|
+
&self,
|
58
|
+
key: &str,
|
59
|
+
limit: u64,
|
60
|
+
timeout: Duration,
|
61
|
+
) -> Result<u64, RateLimitError>;
|
62
|
+
|
63
|
+
/// Returns self as Any for downcasting
|
64
|
+
fn as_any(&self) -> &dyn Any;
|
65
|
+
}
|
66
|
+
|
67
|
+
/// A Redis-backed rate limiter using an async connection manager.
|
68
|
+
/// This uses a TLS-enabled connection when the URL is prefixed with "rediss://".
|
69
|
+
#[derive(Clone)]
|
70
|
+
pub struct RedisRateLimiter {
|
71
|
+
connection: Arc<ConnectionManager>,
|
72
|
+
increment_script: Script,
|
73
|
+
}
|
74
|
+
|
75
|
+
impl std::fmt::Debug for RedisRateLimiter {
|
76
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
77
|
+
f.debug_struct("RedisRateLimiter").finish()
|
78
|
+
}
|
79
|
+
}
|
80
|
+
|
81
|
+
impl RedisRateLimiter {
|
82
|
+
/// Constructs a new RedisRateLimiter with a timeout.
|
83
|
+
///
|
84
|
+
/// Use a connection URL like:
|
85
|
+
/// - Standard: "redis://host:port/db"
|
86
|
+
/// - With auth: "redis://:password@host:port/db"
|
87
|
+
/// - With TLS: "rediss://host:port/db"
|
88
|
+
/// - With TLS and auth: "rediss://:password@host:port/db"
|
89
|
+
pub async fn new(connection_url: &str) -> Result<Self, RateLimitError> {
|
90
|
+
// Set a reasonable timeout for connection attempts (5 seconds)
|
91
|
+
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);
|
92
|
+
|
93
|
+
// Parse URL to extract auth information if provided
|
94
|
+
let url_result = Url::parse(connection_url);
|
95
|
+
if let Err(e) = url_result {
|
96
|
+
tracing::error!("Invalid Redis URL format: {}", e);
|
97
|
+
return Err(RateLimitError::RedisError(RedisError::from((
|
98
|
+
redis::ErrorKind::InvalidClientConfig,
|
99
|
+
"Invalid Redis URL format",
|
100
|
+
))));
|
101
|
+
}
|
102
|
+
|
103
|
+
// Create a Redis client
|
104
|
+
let client = Client::open(connection_url).map_err(RateLimitError::RedisError)?;
|
105
|
+
|
106
|
+
// Use tokio timeout to prevent hanging on connection attempt
|
107
|
+
let connection_manager_result =
|
108
|
+
timeout(CONNECTION_TIMEOUT, ConnectionManager::new(client)).await;
|
109
|
+
|
110
|
+
// Handle timeout and connection errors
|
111
|
+
let connection_manager = match connection_manager_result {
|
112
|
+
Ok(result) => result.map_err(RateLimitError::RedisError)?,
|
113
|
+
Err(_) => return Err(RateLimitError::ConnectionTimeout),
|
114
|
+
};
|
115
|
+
|
116
|
+
// Create the Lua script once when initializing the rate limiter
|
117
|
+
let increment_script = Script::new(
|
118
|
+
r#"
|
119
|
+
local current = redis.call('INCR', KEYS[1])
|
120
|
+
if redis.call('TTL', KEYS[1]) < 0 then
|
121
|
+
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
122
|
+
end
|
123
|
+
return current
|
124
|
+
"#,
|
125
|
+
);
|
126
|
+
|
127
|
+
Ok(Self {
|
128
|
+
connection: Arc::new(connection_manager),
|
129
|
+
increment_script,
|
130
|
+
})
|
131
|
+
}
|
132
|
+
|
133
|
+
/// Bans an IP address for the specified duration
|
134
|
+
pub async fn ban_ip(
|
135
|
+
&self,
|
136
|
+
ip: &str,
|
137
|
+
reason: &str,
|
138
|
+
duration: Duration,
|
139
|
+
) -> Result<(), RateLimitError> {
|
140
|
+
let ban_key = format!("ban:ip:{}", ip);
|
141
|
+
let timeout_secs = duration.as_secs();
|
142
|
+
let mut connection = (*self.connection).clone();
|
143
|
+
|
144
|
+
// Set the ban with the reason as the value
|
145
|
+
let _: () = redis::cmd("SET")
|
146
|
+
.arg(&ban_key)
|
147
|
+
.arg(reason)
|
148
|
+
.arg("EX")
|
149
|
+
.arg(timeout_secs)
|
150
|
+
.query_async(&mut connection)
|
151
|
+
.await
|
152
|
+
.map_err(RateLimitError::RedisError)?;
|
153
|
+
|
154
|
+
Ok(())
|
155
|
+
}
|
156
|
+
|
157
|
+
/// Checks if an IP address is banned
|
158
|
+
pub async fn is_banned(&self, ip: &str) -> Result<Option<String>, RateLimitError> {
|
159
|
+
let ban_key = format!("ban:ip:{}", ip);
|
160
|
+
let mut connection = (*self.connection).clone();
|
161
|
+
|
162
|
+
// Get the ban reason if it exists
|
163
|
+
let result: Option<String> = redis::cmd("GET")
|
164
|
+
.arg(&ban_key)
|
165
|
+
.query_async(&mut connection)
|
166
|
+
.await
|
167
|
+
.map_err(RateLimitError::RedisError)?;
|
168
|
+
|
169
|
+
Ok(result)
|
170
|
+
}
|
171
|
+
}
|
172
|
+
|
173
|
+
#[async_trait]
|
174
|
+
impl RateLimiter for RedisRateLimiter {
|
175
|
+
async fn increment(&self, key: &str, timeout: Duration) -> Result<u64, RateLimitError> {
|
176
|
+
let timeout_secs = timeout.as_secs();
|
177
|
+
let mut connection = (*self.connection).clone();
|
178
|
+
|
179
|
+
// Use the pre-compiled script (atomic approach)
|
180
|
+
match self
|
181
|
+
.increment_script
|
182
|
+
.key(key)
|
183
|
+
.arg(timeout_secs)
|
184
|
+
.invoke_async(&mut connection)
|
185
|
+
.await
|
186
|
+
{
|
187
|
+
Ok(value) => Ok(value),
|
188
|
+
Err(err) => {
|
189
|
+
// Log the error but return 0 to fail open
|
190
|
+
tracing::warn!("Redis rate limit error: {}", err);
|
191
|
+
Ok(0)
|
192
|
+
}
|
193
|
+
}
|
194
|
+
}
|
195
|
+
|
196
|
+
async fn check_limit(
|
197
|
+
&self,
|
198
|
+
key: &str,
|
199
|
+
limit: u64,
|
200
|
+
timeout: Duration,
|
201
|
+
) -> Result<u64, RateLimitError> {
|
202
|
+
match self.increment(key, timeout).await {
|
203
|
+
Ok(count) if count <= limit => Ok(count),
|
204
|
+
Ok(count) if count > limit => Err(RateLimitError::RateLimitExceeded { limit, count }),
|
205
|
+
// For any error or other case, fail open
|
206
|
+
_ => Ok(0),
|
207
|
+
}
|
208
|
+
}
|
209
|
+
|
210
|
+
fn as_any(&self) -> &dyn Any {
|
211
|
+
self
|
212
|
+
}
|
213
|
+
}
|
214
|
+
|
215
|
+
/// An entry in the in-memory rate limiter
|
216
|
+
#[derive(Debug)]
|
217
|
+
struct RateLimitEntry {
|
218
|
+
count: u64,
|
219
|
+
expires_at: Instant,
|
220
|
+
}
|
221
|
+
|
222
|
+
/// An in-memory implementation of the RateLimiter trait
|
223
|
+
#[derive(Debug)]
|
224
|
+
pub struct InMemoryRateLimiter {
|
225
|
+
entries: RwLock<HashMap<String, RateLimitEntry>>,
|
226
|
+
}
|
227
|
+
|
228
|
+
impl InMemoryRateLimiter {
|
229
|
+
/// Creates a new in-memory rate limiter
|
230
|
+
pub fn new() -> Self {
|
231
|
+
Self {
|
232
|
+
entries: RwLock::new(HashMap::new()),
|
233
|
+
}
|
234
|
+
}
|
235
|
+
|
236
|
+
/// Cleans up expired entries
|
237
|
+
async fn cleanup(&self) {
|
238
|
+
// Try to get the write lock, but fail open if we can't
|
239
|
+
if let Ok(mut entries) = self.entries.try_write() {
|
240
|
+
let now = Instant::now();
|
241
|
+
entries.retain(|_, entry| entry.expires_at > now);
|
242
|
+
}
|
243
|
+
}
|
244
|
+
|
245
|
+
/// Bans an IP address for the specified duration
|
246
|
+
pub async fn ban_ip(
|
247
|
+
&self,
|
248
|
+
ip: &str,
|
249
|
+
_: &str,
|
250
|
+
duration: Duration,
|
251
|
+
) -> Result<(), RateLimitError> {
|
252
|
+
let now = Instant::now();
|
253
|
+
let ban_key = format!("ban:ip:{}", ip);
|
254
|
+
|
255
|
+
let mut entries = self.entries.try_write().map_err(|e| {
|
256
|
+
tracing::error!("Failed to acquire write lock: {}", e);
|
257
|
+
RateLimitError::LockError
|
258
|
+
})?;
|
259
|
+
|
260
|
+
entries.insert(
|
261
|
+
ban_key,
|
262
|
+
RateLimitEntry {
|
263
|
+
count: 1, // Use count=1 to indicate banned
|
264
|
+
expires_at: now + duration,
|
265
|
+
},
|
266
|
+
);
|
267
|
+
|
268
|
+
Ok(())
|
269
|
+
}
|
270
|
+
|
271
|
+
/// Checks if an IP address is banned
|
272
|
+
pub async fn is_banned(&self, ip: &str) -> Result<Option<String>, RateLimitError> {
|
273
|
+
let now = Instant::now();
|
274
|
+
let ban_key = format!("ban:ip:{}", ip);
|
275
|
+
|
276
|
+
let entries = self.entries.try_read().map_err(|e| {
|
277
|
+
tracing::error!("Failed to acquire read lock: {}", e);
|
278
|
+
RateLimitError::LockError
|
279
|
+
})?;
|
280
|
+
|
281
|
+
if let Some(entry) = entries.get(&ban_key) {
|
282
|
+
if entry.expires_at > now {
|
283
|
+
// IP is banned, return a generic reason since we don't store reasons
|
284
|
+
return Ok(Some("IP address banned".to_string()));
|
285
|
+
}
|
286
|
+
}
|
287
|
+
|
288
|
+
Ok(None)
|
289
|
+
}
|
290
|
+
}
|
291
|
+
|
292
|
+
impl Default for InMemoryRateLimiter {
|
293
|
+
fn default() -> Self {
|
294
|
+
Self::new()
|
295
|
+
}
|
296
|
+
}
|
297
|
+
|
298
|
+
#[async_trait]
|
299
|
+
impl RateLimiter for InMemoryRateLimiter {
|
300
|
+
async fn increment(&self, key: &str, timeout: Duration) -> Result<u64, RateLimitError> {
|
301
|
+
// Periodically clean up expired entries
|
302
|
+
if rand::rng().random_bool(0.01) {
|
303
|
+
// 1% chance on each call
|
304
|
+
self.cleanup().await;
|
305
|
+
}
|
306
|
+
|
307
|
+
let now = Instant::now();
|
308
|
+
|
309
|
+
let mut entries = self.entries.write().await;
|
310
|
+
|
311
|
+
let entry = entries
|
312
|
+
.entry(key.to_string())
|
313
|
+
.or_insert_with(|| RateLimitEntry {
|
314
|
+
count: 0,
|
315
|
+
expires_at: now + timeout,
|
316
|
+
});
|
317
|
+
|
318
|
+
// Update expiry time if it's an existing entry
|
319
|
+
entry.expires_at = now + timeout;
|
320
|
+
entry.count += 1;
|
321
|
+
|
322
|
+
Ok(entry.count)
|
323
|
+
}
|
324
|
+
|
325
|
+
async fn check_limit(
|
326
|
+
&self,
|
327
|
+
key: &str,
|
328
|
+
limit: u64,
|
329
|
+
timeout: Duration,
|
330
|
+
) -> Result<u64, RateLimitError> {
|
331
|
+
match self.increment(key, timeout).await {
|
332
|
+
Ok(count) if count <= limit => Ok(count),
|
333
|
+
Ok(count) if count > limit => Err(RateLimitError::RateLimitExceeded { limit, count }),
|
334
|
+
// For any error or other case, fail open
|
335
|
+
_ => Ok(0),
|
336
|
+
}
|
337
|
+
}
|
338
|
+
|
339
|
+
fn as_any(&self) -> &dyn Any {
|
340
|
+
self
|
341
|
+
}
|
342
|
+
}
|
343
|
+
|
344
|
+
/// Enum to represent different types of rate limiters that can ban IPs
|
345
|
+
#[derive(Debug, Clone)]
|
346
|
+
pub enum BanManager {
|
347
|
+
Redis(Arc<RedisRateLimiter>),
|
348
|
+
InMemory(Arc<InMemoryRateLimiter>),
|
349
|
+
}
|
350
|
+
|
351
|
+
impl BanManager {
|
352
|
+
/// Bans an IP address for the specified duration
|
353
|
+
pub async fn ban_ip(
|
354
|
+
&self,
|
355
|
+
ip: &str,
|
356
|
+
reason: &str,
|
357
|
+
duration: Duration,
|
358
|
+
) -> Result<(), RateLimitError> {
|
359
|
+
match self {
|
360
|
+
BanManager::Redis(limiter) => limiter.ban_ip(ip, reason, duration).await,
|
361
|
+
BanManager::InMemory(limiter) => limiter.ban_ip(ip, reason, duration).await,
|
362
|
+
}
|
363
|
+
}
|
364
|
+
|
365
|
+
/// Checks if an IP address is banned
|
366
|
+
pub async fn is_banned(&self, ip: &str) -> Result<Option<String>, RateLimitError> {
|
367
|
+
match self {
|
368
|
+
BanManager::Redis(limiter) => limiter.is_banned(ip).await,
|
369
|
+
BanManager::InMemory(limiter) => limiter.is_banned(ip).await,
|
370
|
+
}
|
371
|
+
}
|
372
|
+
}
|
373
|
+
|
374
|
+
/// Utility function to create a rate limit key for a specific minute
|
375
|
+
pub fn create_rate_limit_key(api_key: &str, resource: &str) -> String {
|
376
|
+
// Get the current minute number (0-59)
|
377
|
+
let now = std::time::SystemTime::now()
|
378
|
+
.duration_since(std::time::UNIX_EPOCH)
|
379
|
+
.unwrap_or_default();
|
380
|
+
|
381
|
+
let minutes = now.as_secs() / 60 % 60;
|
382
|
+
format!("ratelimit:{}:{}:{}", api_key, resource, minutes)
|
383
|
+
}
|
384
|
+
|
385
|
+
/// Utility function to create a ban key for an IP address
|
386
|
+
pub fn create_ban_key(ip: &str) -> String {
|
387
|
+
format!("ban:ip:{}", ip)
|
388
|
+
}
|
389
|
+
|
390
|
+
// Global map of URL to mutex to ensure only one connection attempt per URL at a time
|
391
|
+
static CONNECTION_LOCKS: LazyLock<Mutex<HashMap<String, Arc<AsyncMutex<()>>>>> =
|
392
|
+
LazyLock::new(|| Mutex::new(HashMap::new()));
|
393
|
+
|
394
|
+
/// A global store for rate limiters, indexed by connection URL
|
395
|
+
pub struct RateLimiterStore {
|
396
|
+
redis_limiters: Mutex<HashMap<String, Arc<RedisRateLimiter>>>,
|
397
|
+
memory_limiter: Arc<InMemoryRateLimiter>,
|
398
|
+
// Track known bad Redis URLs to avoid repeated connection attempts
|
399
|
+
failed_urls: Mutex<HashSet<String>>,
|
400
|
+
}
|
401
|
+
|
402
|
+
impl RateLimiterStore {
|
403
|
+
/// Create a new store with a single in-memory rate limiter
|
404
|
+
fn new() -> Self {
|
405
|
+
Self {
|
406
|
+
redis_limiters: Mutex::new(HashMap::new()),
|
407
|
+
memory_limiter: Arc::new(InMemoryRateLimiter::new()),
|
408
|
+
failed_urls: Mutex::new(HashSet::new()),
|
409
|
+
}
|
410
|
+
}
|
411
|
+
|
412
|
+
/// Get an in-memory rate limiter
|
413
|
+
pub fn get_memory_limiter(&self) -> Arc<InMemoryRateLimiter> {
|
414
|
+
self.memory_limiter.clone()
|
415
|
+
}
|
416
|
+
|
417
|
+
/// Get a Redis rate limiter for the given connection URL, creating one if it doesn't exist
|
418
|
+
pub async fn get_redis_limiter(
|
419
|
+
&self,
|
420
|
+
connection_url: &str,
|
421
|
+
) -> Result<Arc<RedisRateLimiter>, RateLimitError> {
|
422
|
+
// First check if this URL is known to fail
|
423
|
+
{
|
424
|
+
let failed_urls = self.failed_urls.lock().unwrap_or_else(|e| e.into_inner());
|
425
|
+
if failed_urls.contains(connection_url) {
|
426
|
+
return Err(RateLimitError::ConnectionTimeout);
|
427
|
+
}
|
428
|
+
}
|
429
|
+
|
430
|
+
// Then check if we already have a limiter for this URL
|
431
|
+
{
|
432
|
+
let limiters = self
|
433
|
+
.redis_limiters
|
434
|
+
.lock()
|
435
|
+
.unwrap_or_else(|e| e.into_inner());
|
436
|
+
if let Some(limiter) = limiters.get(connection_url) {
|
437
|
+
return Ok(limiter.clone());
|
438
|
+
}
|
439
|
+
}
|
440
|
+
|
441
|
+
// Get a dedicated mutex for this URL or create a new one if it doesn't exist
|
442
|
+
let url_mutex = {
|
443
|
+
let mut locks = CONNECTION_LOCKS.lock().unwrap_or_else(|e| e.into_inner());
|
444
|
+
|
445
|
+
// Get or create the mutex for this URL
|
446
|
+
locks
|
447
|
+
.entry(connection_url.to_string())
|
448
|
+
.or_insert_with(|| Arc::new(AsyncMutex::new(())))
|
449
|
+
.clone()
|
450
|
+
};
|
451
|
+
|
452
|
+
// Acquire the mutex with a timeout to avoid deadlocks
|
453
|
+
let lock_result = timeout(Duration::from_secs(5), url_mutex.lock()).await;
|
454
|
+
let _guard = match lock_result {
|
455
|
+
Ok(guard) => guard,
|
456
|
+
Err(_) => {
|
457
|
+
tracing::warn!("Timed out waiting for lock on URL: {}", connection_url);
|
458
|
+
return Err(RateLimitError::LockError);
|
459
|
+
}
|
460
|
+
};
|
461
|
+
|
462
|
+
// Check again if another thread created the limiter while we were waiting
|
463
|
+
{
|
464
|
+
let limiters = self
|
465
|
+
.redis_limiters
|
466
|
+
.lock()
|
467
|
+
.unwrap_or_else(|e| e.into_inner());
|
468
|
+
if let Some(limiter) = limiters.get(connection_url) {
|
469
|
+
return Ok(limiter.clone());
|
470
|
+
}
|
471
|
+
}
|
472
|
+
|
473
|
+
// Create a new limiter
|
474
|
+
tracing::info!("Initializing Redis rate limiter for {}", connection_url);
|
475
|
+
match RedisRateLimiter::new(connection_url).await {
|
476
|
+
Ok(limiter) => {
|
477
|
+
let limiter = Arc::new(limiter);
|
478
|
+
|
479
|
+
// Store it for future use
|
480
|
+
let mut limiters = self
|
481
|
+
.redis_limiters
|
482
|
+
.lock()
|
483
|
+
.unwrap_or_else(|e| e.into_inner());
|
484
|
+
limiters.insert(connection_url.to_string(), limiter.clone());
|
485
|
+
|
486
|
+
Ok(limiter)
|
487
|
+
}
|
488
|
+
Err(e) => {
|
489
|
+
tracing::error!("Failed to initialize Redis rate limiter: {}", e);
|
490
|
+
// Cache the failure
|
491
|
+
let mut failed_urls = self.failed_urls.lock().unwrap_or_else(|e| e.into_inner());
|
492
|
+
failed_urls.insert(connection_url.to_string());
|
493
|
+
Err(e)
|
494
|
+
}
|
495
|
+
}
|
496
|
+
}
|
497
|
+
|
498
|
+
/// Get a BanManager for the given RateLimiterConfig
|
499
|
+
pub async fn get_ban_manager(
|
500
|
+
&self,
|
501
|
+
config: &RateLimiterConfig,
|
502
|
+
) -> Result<BanManager, RateLimitError> {
|
503
|
+
match config {
|
504
|
+
RateLimiterConfig::Memory => {
|
505
|
+
tracing::debug!("Using in-memory ban manager");
|
506
|
+
Ok(BanManager::InMemory(self.get_memory_limiter()))
|
507
|
+
}
|
508
|
+
RateLimiterConfig::Redis { connection_url } => {
|
509
|
+
match self.get_redis_limiter(connection_url).await {
|
510
|
+
Ok(limiter) => Ok(BanManager::Redis(limiter)),
|
511
|
+
Err(_) => Ok(BanManager::InMemory(self.get_memory_limiter())),
|
512
|
+
}
|
513
|
+
}
|
514
|
+
}
|
515
|
+
}
|
516
|
+
}
|
517
|
+
|
518
|
+
/// Global store of rate limiters
|
519
|
+
pub static RATE_LIMITER_STORE: LazyLock<RateLimiterStore> = LazyLock::new(RateLimiterStore::new);
|
520
|
+
|
521
|
+
/// Convenience function to get an in-memory rate limiter
|
522
|
+
pub fn get_memory_rate_limiter() -> Arc<impl RateLimiter> {
|
523
|
+
RATE_LIMITER_STORE.get_memory_limiter()
|
524
|
+
}
|
525
|
+
|
526
|
+
/// Convenience function to get a Redis rate limiter by connection URL
|
527
|
+
pub async fn get_redis_rate_limiter(
|
528
|
+
connection_url: &str,
|
529
|
+
) -> Result<Arc<impl RateLimiter>, RateLimitError> {
|
530
|
+
RATE_LIMITER_STORE.get_redis_limiter(connection_url).await
|
531
|
+
}
|
532
|
+
|
533
|
+
/// Get a rate limiter based on configuration
|
534
|
+
pub async fn get_rate_limiter(
|
535
|
+
config: &RateLimiterConfig,
|
536
|
+
) -> Result<Arc<dyn RateLimiter>, RateLimitError> {
|
537
|
+
match config {
|
538
|
+
RateLimiterConfig::Memory => Ok(get_memory_rate_limiter() as Arc<dyn RateLimiter>),
|
539
|
+
RateLimiterConfig::Redis { connection_url } => {
|
540
|
+
match get_redis_rate_limiter(connection_url).await {
|
541
|
+
Ok(limiter) => Ok(limiter as Arc<dyn RateLimiter>),
|
542
|
+
Err(_) => Ok(get_memory_rate_limiter() as Arc<dyn RateLimiter>),
|
543
|
+
}
|
544
|
+
}
|
545
|
+
}
|
546
|
+
}
|
547
|
+
|
548
|
+
/// Get a ban manager based on configuration
|
549
|
+
pub async fn get_ban_manager(config: &RateLimiterConfig) -> Result<BanManager, RateLimitError> {
|
550
|
+
RATE_LIMITER_STORE.get_ban_manager(config).await
|
551
|
+
}
|
552
|
+
|
553
|
+
/// Configuration for rate limiters
|
554
|
+
#[derive(Debug, Clone, Deserialize)]
|
555
|
+
pub enum RateLimiterConfig {
|
556
|
+
/// Use an in-memory rate limiter
|
557
|
+
#[serde(rename(deserialize = "in_memory"))]
|
558
|
+
Memory,
|
559
|
+
/// Use a Redis-backed rate limiter
|
560
|
+
#[serde(rename(deserialize = "redis"))]
|
561
|
+
Redis {
|
562
|
+
/// Connection URL, including database number if needed (e.g., "redis://localhost:6379/0")
|
563
|
+
connection_url: String,
|
564
|
+
},
|
565
|
+
}
|
@@ -0,0 +1,11 @@
|
|
1
|
+
use crate::ruby_types::{itsi_grpc_request::ItsiGrpcRequest, itsi_http_request::ItsiHttpRequest};
|
2
|
+
use itsi_rb_helpers::HeapValue;
|
3
|
+
use magnus::block::Proc;
|
4
|
+
use std::sync::Arc;
|
5
|
+
|
6
|
+
#[derive(Debug)]
|
7
|
+
pub enum RequestJob {
|
8
|
+
ProcessHttpRequest(ItsiHttpRequest, Arc<HeapValue<Proc>>),
|
9
|
+
ProcessGrpcRequest(ItsiGrpcRequest, Arc<HeapValue<Proc>>),
|
10
|
+
Shutdown,
|
11
|
+
}
|