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.
Files changed (105) hide show
  1. checksums.yaml +4 -4
  2. data/CODE_OF_CONDUCT.md +7 -0
  3. data/Cargo.lock +75 -14
  4. data/README.md +5 -0
  5. data/_index.md +7 -0
  6. data/ext/itsi_error/src/lib.rs +9 -0
  7. data/ext/itsi_error/target/debug/build/clang-sys-da71b0344e568175/out/common.rs +355 -0
  8. data/ext/itsi_error/target/debug/build/clang-sys-da71b0344e568175/out/dynamic.rs +276 -0
  9. data/ext/itsi_error/target/debug/build/clang-sys-da71b0344e568175/out/macros.rs +49 -0
  10. data/ext/itsi_error/target/debug/build/rb-sys-49f554618693db24/out/bindings-0.9.110-mri-arm64-darwin23-3.4.2.rs +8865 -0
  11. data/ext/itsi_error/target/debug/incremental/itsi_error-1mmt5sux7jb0i/s-h510z7m8v9-0bxu7yd.lock +0 -0
  12. data/ext/itsi_error/target/debug/incremental/itsi_error-2vn3jey74oiw0/s-h5113n0e7e-1v5qzs6.lock +0 -0
  13. data/ext/itsi_error/target/debug/incremental/itsi_error-37uv9dicz7awp/s-h510ykifhe-0tbnep2.lock +0 -0
  14. data/ext/itsi_error/target/debug/incremental/itsi_error-37uv9dicz7awp/s-h510yyocpj-0tz7ug7.lock +0 -0
  15. data/ext/itsi_error/target/debug/incremental/itsi_error-37uv9dicz7awp/s-h510z0xc8g-14ol18k.lock +0 -0
  16. data/ext/itsi_error/target/debug/incremental/itsi_error-3g5qf4y7d54uj/s-h5113n0e7d-1trk8on.lock +0 -0
  17. data/ext/itsi_error/target/debug/incremental/itsi_error-3lpfftm45d3e2/s-h510z7m8r3-1pxp20o.lock +0 -0
  18. data/ext/itsi_error/target/debug/incremental/itsi_error-3o4qownhl3d7n/s-h510ykifek-1uxasnk.lock +0 -0
  19. data/ext/itsi_error/target/debug/incremental/itsi_error-3o4qownhl3d7n/s-h510yyocki-11u37qm.lock +0 -0
  20. data/ext/itsi_error/target/debug/incremental/itsi_error-3o4qownhl3d7n/s-h510z0xc93-0pmy0zm.lock +0 -0
  21. data/ext/itsi_rb_helpers/Cargo.toml +1 -0
  22. data/ext/itsi_rb_helpers/src/heap_value.rs +18 -0
  23. data/ext/itsi_rb_helpers/src/lib.rs +34 -7
  24. data/ext/itsi_rb_helpers/target/debug/build/clang-sys-da71b0344e568175/out/common.rs +355 -0
  25. data/ext/itsi_rb_helpers/target/debug/build/clang-sys-da71b0344e568175/out/dynamic.rs +276 -0
  26. data/ext/itsi_rb_helpers/target/debug/build/clang-sys-da71b0344e568175/out/macros.rs +49 -0
  27. 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
  28. data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-040pxg6yhb3g3/s-h5113n7a1b-03bwlt4.lock +0 -0
  29. data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-131g1u4dzkt1a/s-h51113xnh3-1eik1ip.lock +0 -0
  30. data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-131g1u4dzkt1a/s-h5111704jj-0g4rj8x.lock +0 -0
  31. data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-1q2d3drtxrzs5/s-h5113n79yl-0bxcqc5.lock +0 -0
  32. data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-374a9h7ovycj0/s-h51113xoox-10de2hp.lock +0 -0
  33. data/ext/itsi_rb_helpers/target/debug/incremental/itsi_rb_helpers-374a9h7ovycj0/s-h5111704w7-0vdq7gq.lock +0 -0
  34. data/ext/itsi_server/Cargo.toml +69 -30
  35. data/ext/itsi_server/src/lib.rs +79 -147
  36. data/ext/itsi_server/src/{body_proxy → ruby_types/itsi_body_proxy}/big_bytes.rs +10 -5
  37. data/ext/itsi_server/src/{body_proxy/itsi_body_proxy.rs → ruby_types/itsi_body_proxy/mod.rs} +22 -3
  38. data/ext/itsi_server/src/ruby_types/itsi_grpc_request.rs +147 -0
  39. data/ext/itsi_server/src/ruby_types/itsi_grpc_response.rs +19 -0
  40. data/ext/itsi_server/src/ruby_types/itsi_grpc_stream/mod.rs +216 -0
  41. data/ext/itsi_server/src/{request/itsi_request.rs → ruby_types/itsi_http_request.rs} +101 -117
  42. data/ext/itsi_server/src/{response/itsi_response.rs → ruby_types/itsi_http_response.rs} +72 -41
  43. data/ext/itsi_server/src/ruby_types/itsi_server/file_watcher.rs +225 -0
  44. data/ext/itsi_server/src/ruby_types/itsi_server/itsi_server_config.rs +355 -0
  45. data/ext/itsi_server/src/ruby_types/itsi_server.rs +82 -0
  46. data/ext/itsi_server/src/ruby_types/mod.rs +55 -0
  47. data/ext/itsi_server/src/server/bind.rs +13 -5
  48. data/ext/itsi_server/src/server/byte_frame.rs +32 -0
  49. data/ext/itsi_server/src/server/cache_store.rs +74 -0
  50. data/ext/itsi_server/src/server/itsi_service.rs +172 -0
  51. data/ext/itsi_server/src/server/lifecycle_event.rs +3 -0
  52. data/ext/itsi_server/src/server/listener.rs +102 -2
  53. data/ext/itsi_server/src/server/middleware_stack/middleware.rs +153 -0
  54. data/ext/itsi_server/src/server/middleware_stack/middlewares/allow_list.rs +47 -0
  55. data/ext/itsi_server/src/server/middleware_stack/middlewares/auth_api_key.rs +58 -0
  56. data/ext/itsi_server/src/server/middleware_stack/middlewares/auth_basic.rs +82 -0
  57. data/ext/itsi_server/src/server/middleware_stack/middlewares/auth_jwt.rs +321 -0
  58. data/ext/itsi_server/src/server/middleware_stack/middlewares/cache_control.rs +139 -0
  59. data/ext/itsi_server/src/server/middleware_stack/middlewares/compression.rs +300 -0
  60. data/ext/itsi_server/src/server/middleware_stack/middlewares/cors.rs +287 -0
  61. data/ext/itsi_server/src/server/middleware_stack/middlewares/deny_list.rs +48 -0
  62. data/ext/itsi_server/src/server/middleware_stack/middlewares/error_response.rs +127 -0
  63. data/ext/itsi_server/src/server/middleware_stack/middlewares/etag.rs +191 -0
  64. data/ext/itsi_server/src/server/middleware_stack/middlewares/grpc_service.rs +72 -0
  65. data/ext/itsi_server/src/server/middleware_stack/middlewares/header_interpretation.rs +85 -0
  66. data/ext/itsi_server/src/server/middleware_stack/middlewares/intrusion_protection.rs +195 -0
  67. data/ext/itsi_server/src/server/middleware_stack/middlewares/log_requests.rs +82 -0
  68. data/ext/itsi_server/src/server/middleware_stack/middlewares/mod.rs +82 -0
  69. data/ext/itsi_server/src/server/middleware_stack/middlewares/proxy.rs +216 -0
  70. data/ext/itsi_server/src/server/middleware_stack/middlewares/rate_limit.rs +124 -0
  71. data/ext/itsi_server/src/server/middleware_stack/middlewares/redirect.rs +76 -0
  72. data/ext/itsi_server/src/server/middleware_stack/middlewares/request_headers.rs +43 -0
  73. data/ext/itsi_server/src/server/middleware_stack/middlewares/response_headers.rs +34 -0
  74. data/ext/itsi_server/src/server/middleware_stack/middlewares/ruby_app.rs +93 -0
  75. data/ext/itsi_server/src/server/middleware_stack/middlewares/static_assets.rs +162 -0
  76. data/ext/itsi_server/src/server/middleware_stack/middlewares/string_rewrite.rs +158 -0
  77. data/ext/itsi_server/src/server/middleware_stack/middlewares/token_source.rs +12 -0
  78. data/ext/itsi_server/src/server/middleware_stack/mod.rs +315 -0
  79. data/ext/itsi_server/src/server/mod.rs +8 -1
  80. data/ext/itsi_server/src/server/process_worker.rs +38 -12
  81. data/ext/itsi_server/src/server/rate_limiter.rs +565 -0
  82. data/ext/itsi_server/src/server/request_job.rs +11 -0
  83. data/ext/itsi_server/src/server/serve_strategy/cluster_mode.rs +119 -42
  84. data/ext/itsi_server/src/server/serve_strategy/mod.rs +9 -6
  85. data/ext/itsi_server/src/server/serve_strategy/single_mode.rs +256 -111
  86. data/ext/itsi_server/src/server/signal.rs +19 -0
  87. data/ext/itsi_server/src/server/static_file_server.rs +984 -0
  88. data/ext/itsi_server/src/server/thread_worker.rs +139 -94
  89. data/ext/itsi_server/src/server/types.rs +43 -0
  90. data/ext/itsi_tracing/Cargo.toml +1 -0
  91. data/ext/itsi_tracing/src/lib.rs +216 -45
  92. data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-0994n8rpvvt9m/s-h510hfz1f6-1kbycmq.lock +0 -0
  93. data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-0bob7bf4yq34i/s-h5113125h5-0lh4rag.lock +0 -0
  94. data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2fcodulrxbbxo/s-h510h2infk-0hp5kjw.lock +0 -0
  95. data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2iak63r1woi1l/s-h510h2in4q-0kxfzw1.lock +0 -0
  96. data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2kk4qj9gn5dg2/s-h5113124kv-0enwon2.lock +0 -0
  97. data/ext/itsi_tracing/target/debug/incremental/itsi_tracing-2mwo0yas7dtw4/s-h510hfz1ha-1udgpei.lock +0 -0
  98. data/lib/itsi/scheduler/version.rb +1 -1
  99. data/lib/itsi/scheduler.rb +2 -2
  100. metadata +77 -12
  101. data/ext/itsi_server/extconf.rb +0 -6
  102. data/ext/itsi_server/src/body_proxy/mod.rs +0 -2
  103. data/ext/itsi_server/src/request/mod.rs +0 -1
  104. data/ext/itsi_server/src/response/mod.rs +0 -1
  105. data/ext/itsi_server/src/server/itsi_server.rs +0 -288
@@ -0,0 +1,127 @@
1
+ use crate::server::static_file_server::ROOT_STATIC_FILE_SERVER;
2
+ use crate::server::types::RequestExt;
3
+ use crate::server::{
4
+ itsi_service::RequestContext,
5
+ types::{HttpRequest, HttpResponse},
6
+ };
7
+
8
+ use bytes::Bytes;
9
+ use either::Either;
10
+ use http::Response;
11
+ use http_body_util::{combinators::BoxBody, Full};
12
+ use itsi_error::ItsiError;
13
+ use serde::Deserialize;
14
+ use std::path::{Path, PathBuf};
15
+
16
+ #[derive(Debug, Clone, Deserialize)]
17
+ /// Filters can each have a customizable error response.
18
+ /// They can:
19
+ /// * Return Plain-text
20
+ /// * Return HTML
21
+ /// * Return JSON
22
+ pub struct ErrorResponse {
23
+ code: u16,
24
+ plaintext: Option<String>,
25
+ html: Option<PathBuf>,
26
+ json: Option<serde_json::Value>,
27
+ default: ErrorFormat,
28
+ }
29
+
30
+ #[derive(Debug, Clone, Deserialize, Default)]
31
+ enum ErrorFormat {
32
+ #[default]
33
+ #[serde(rename(deserialize = "plaintext"))]
34
+ Plaintext,
35
+ #[serde(rename(deserialize = "html"))]
36
+ Html,
37
+ #[serde(rename(deserialize = "json"))]
38
+ Json,
39
+ }
40
+
41
+ impl Default for ErrorResponse {
42
+ fn default() -> Self {
43
+ ErrorResponse {
44
+ code: 500,
45
+ plaintext: Some("Error".to_owned()),
46
+ html: None,
47
+ json: None,
48
+ default: ErrorFormat::Plaintext,
49
+ }
50
+ }
51
+ }
52
+
53
+ impl ErrorResponse {
54
+ pub(crate) async fn to_http_response(&self, request: &HttpRequest) -> HttpResponse {
55
+ let accept = request.accept();
56
+ let body = match accept {
57
+ Some(accept) if accept.contains("text/plain") => BoxBody::new(Full::new(Bytes::from(
58
+ self.plaintext.clone().unwrap_or_else(|| "Error".to_owned()),
59
+ ))),
60
+ Some(accept) if accept.contains("text/html") => {
61
+ if let Some(path) = &self.html {
62
+ let path = path.to_str().unwrap();
63
+ let response = ROOT_STATIC_FILE_SERVER.serve_single(path).await;
64
+
65
+ if response.status().is_success() {
66
+ response.into_body()
67
+ } else {
68
+ BoxBody::new(Full::new(Bytes::from("Error")))
69
+ }
70
+ } else {
71
+ BoxBody::new(Full::new(Bytes::from("Error")))
72
+ }
73
+ }
74
+ Some(accept) if accept.contains("application/json") => {
75
+ BoxBody::new(Full::new(Bytes::from(
76
+ self.json
77
+ .as_ref()
78
+ .map(|json| json.to_string())
79
+ .unwrap_or_else(|| "Error".to_owned()),
80
+ )))
81
+ }
82
+ _ => match self.default {
83
+ ErrorFormat::Plaintext => BoxBody::new(Full::new(Bytes::from(
84
+ self.plaintext.clone().unwrap_or_else(|| "Error".to_owned()),
85
+ ))),
86
+ ErrorFormat::Html => {
87
+ if let Some(path) = &self.html {
88
+ let path = path.to_str().unwrap();
89
+ let response = ROOT_STATIC_FILE_SERVER.serve_single(path).await;
90
+
91
+ if response.status().is_success() {
92
+ response.into_body()
93
+ } else {
94
+ BoxBody::new(Full::new(Bytes::from("Error")))
95
+ }
96
+ } else {
97
+ BoxBody::new(Full::new(Bytes::from("Error")))
98
+ }
99
+ }
100
+ ErrorFormat::Json => BoxBody::new(Full::new(Bytes::from(
101
+ self.json
102
+ .as_ref()
103
+ .map(|json| json.to_string())
104
+ .unwrap_or_else(|| "Error".to_owned()),
105
+ ))),
106
+ },
107
+ };
108
+
109
+ Response::builder().status(self.code).body(body).unwrap()
110
+ }
111
+
112
+ pub async fn before(
113
+ &self,
114
+ req: HttpRequest,
115
+ _context: &mut RequestContext,
116
+ ) -> Result<Either<HttpRequest, HttpResponse>, ItsiError> {
117
+ if let Some(path) = req.uri().path().strip_prefix("/error/") {
118
+ let path = Path::new(path);
119
+ if path.exists() {
120
+ let path = path.to_str().unwrap();
121
+ let response = ROOT_STATIC_FILE_SERVER.serve_single(path).await;
122
+ return Ok(Either::Right(response));
123
+ }
124
+ }
125
+ Ok(Either::Left(req))
126
+ }
127
+ }
@@ -0,0 +1,191 @@
1
+ use super::{FromValue, MiddlewareLayer};
2
+ use crate::server::{itsi_service::RequestContext, types::HttpResponse};
3
+ use async_trait::async_trait;
4
+ use base64::{engine::general_purpose, Engine as _};
5
+ use bytes::{Bytes, BytesMut};
6
+ use either::Either;
7
+ use futures::TryStreamExt;
8
+ use http::{header, HeaderValue, Response, StatusCode};
9
+ use http_body_util::{combinators::BoxBody, BodyExt, Empty, Full};
10
+ use hyper::body::Body;
11
+ use magnus::error::Result;
12
+ use serde::Deserialize;
13
+ use sha2::{Digest, Sha256};
14
+
15
+ #[derive(Debug, Clone, Copy, Deserialize, Default)]
16
+ pub enum ETagType {
17
+ #[serde(rename = "strong")]
18
+ #[default]
19
+ Strong,
20
+ #[serde(rename = "weak")]
21
+ Weak,
22
+ }
23
+
24
+ #[derive(Debug, Clone, Copy, Deserialize, Default)]
25
+ pub enum HashAlgorithm {
26
+ #[serde(rename = "sha256")]
27
+ #[default]
28
+ Sha256,
29
+ #[serde(rename = "md5")]
30
+ Md5,
31
+ }
32
+
33
+ #[derive(Debug, Clone, Deserialize)]
34
+ pub struct ETag {
35
+ #[serde(default)]
36
+ pub r#type: ETagType,
37
+ #[serde(default)]
38
+ pub algorithm: HashAlgorithm,
39
+ #[serde(default)]
40
+ pub min_body_size: usize,
41
+ #[serde(default = "default_true")]
42
+ pub handle_if_none_match: bool,
43
+ }
44
+
45
+ fn default_true() -> bool {
46
+ true
47
+ }
48
+
49
+ #[async_trait]
50
+ impl MiddlewareLayer for ETag {
51
+ async fn before(
52
+ &self,
53
+ req: crate::server::types::HttpRequest,
54
+ context: &mut RequestContext,
55
+ ) -> Result<Either<crate::server::types::HttpRequest, HttpResponse>> {
56
+ // Store if-none-match header in context if present for later use in after hook
57
+ if self.handle_if_none_match {
58
+ if let Some(if_none_match) = req.headers().get(header::IF_NONE_MATCH) {
59
+ if let Ok(etag_value) = if_none_match.to_str() {
60
+ context.set_if_none_match(Some(etag_value.to_string()));
61
+ }
62
+ }
63
+ }
64
+ Ok(Either::Left(req))
65
+ }
66
+
67
+ async fn after(&self, resp: HttpResponse, context: &mut RequestContext) -> HttpResponse {
68
+ // Skip for error responses or responses that shouldn't have ETags
69
+ match resp.status() {
70
+ StatusCode::OK
71
+ | StatusCode::CREATED
72
+ | StatusCode::ACCEPTED
73
+ | StatusCode::NON_AUTHORITATIVE_INFORMATION
74
+ | StatusCode::NO_CONTENT
75
+ | StatusCode::PARTIAL_CONTENT => {}
76
+ _ => return resp,
77
+ }
78
+
79
+ // Skip if already has an ETag
80
+ if resp.headers().contains_key(header::ETAG) {
81
+ return resp;
82
+ }
83
+
84
+ // Skip if Cache-Control: no-store is present
85
+ if let Some(cache_control) = resp.headers().get(header::CACHE_CONTROL) {
86
+ if let Ok(cache_control_str) = cache_control.to_str() {
87
+ if cache_control_str.contains("no-store") {
88
+ return resp;
89
+ }
90
+ }
91
+ }
92
+
93
+ // Check if body is a stream or fixed size using size_hint (similar to compression.rs)
94
+ let body_size = resp.size_hint().exact();
95
+
96
+ // Skip streaming bodies
97
+ if body_size.is_none() {
98
+ return resp;
99
+ }
100
+
101
+ // Skip if body is too small
102
+ if body_size.unwrap_or(0) < self.min_body_size as u64 {
103
+ return resp;
104
+ }
105
+
106
+ let (mut parts, mut body) = resp.into_parts();
107
+ let etag_value = if let Some(existing_etag) = parts.headers.get(header::ETAG) {
108
+ existing_etag.to_str().unwrap_or("").to_string()
109
+ } else {
110
+ // Get the full bytes from the body
111
+ let full_bytes: Bytes = match body
112
+ .into_data_stream()
113
+ .try_fold(BytesMut::new(), |mut acc, chunk| async move {
114
+ acc.extend_from_slice(&chunk);
115
+ Ok(acc)
116
+ })
117
+ .await
118
+ {
119
+ Ok(bytes_mut) => bytes_mut.freeze(),
120
+ Err(_) => return Response::from_parts(parts, BoxBody::new(Empty::new())),
121
+ };
122
+
123
+ let computed_etag = match self.algorithm {
124
+ HashAlgorithm::Sha256 => {
125
+ let mut hasher = Sha256::new();
126
+ hasher.update(&full_bytes);
127
+ let result = hasher.finalize();
128
+ general_purpose::STANDARD.encode(result)
129
+ }
130
+ HashAlgorithm::Md5 => {
131
+ let digest = md5::compute(&full_bytes);
132
+ format!("{:x}", digest)
133
+ }
134
+ };
135
+
136
+ let formatted_etag = match self.r#type {
137
+ ETagType::Strong => format!("\"{}\"", computed_etag),
138
+ ETagType::Weak => format!("W/\"{}\"", computed_etag),
139
+ };
140
+
141
+ if let Ok(value) = HeaderValue::from_str(&formatted_etag) {
142
+ parts.headers.insert(header::ETAG, value);
143
+ }
144
+
145
+ body = Full::new(full_bytes).boxed();
146
+ formatted_etag
147
+ };
148
+
149
+ // Handle 304 Not Modified if we have an If-None-Match header and it matches
150
+ if self.handle_if_none_match {
151
+ if let Some(if_none_match) = context.get_if_none_match() {
152
+ if if_none_match == etag_value || if_none_match == "*" {
153
+ // Return 304 Not Modified without the body
154
+ let mut not_modified = Response::new(BoxBody::new(Empty::new()));
155
+ *not_modified.status_mut() = StatusCode::NOT_MODIFIED;
156
+ // Copy headers we want to preserve
157
+ for (name, value) in parts.headers.iter() {
158
+ if matches!(
159
+ name,
160
+ &header::CACHE_CONTROL
161
+ | &header::CONTENT_LOCATION
162
+ | &header::DATE
163
+ | &header::ETAG
164
+ | &header::EXPIRES
165
+ | &header::VARY
166
+ ) {
167
+ not_modified.headers_mut().insert(name, value.clone());
168
+ }
169
+ }
170
+ return not_modified;
171
+ }
172
+ }
173
+ }
174
+
175
+ // Recreate response with the original body and the ETag header
176
+ Response::from_parts(parts, body)
177
+ }
178
+ }
179
+
180
+ impl Default for ETag {
181
+ fn default() -> Self {
182
+ Self {
183
+ r#type: ETagType::Strong,
184
+ algorithm: HashAlgorithm::Sha256,
185
+ min_body_size: 0,
186
+ handle_if_none_match: true,
187
+ }
188
+ }
189
+ }
190
+
191
+ impl FromValue for ETag {}
@@ -0,0 +1,72 @@
1
+ use super::MiddlewareLayer;
2
+ use crate::{
3
+ ruby_types::itsi_grpc_request::ItsiGrpcRequest,
4
+ server::{
5
+ itsi_service::RequestContext,
6
+ types::{HttpRequest, HttpResponse},
7
+ },
8
+ };
9
+ use async_trait::async_trait;
10
+ use derive_more::Debug;
11
+ use either::Either;
12
+ use http::StatusCode;
13
+ use itsi_rb_helpers::{HeapVal, HeapValue};
14
+ use magnus::{block::Proc, error::Result, value::ReprValue, Symbol, Value};
15
+ use std::sync::Arc;
16
+
17
+ #[derive(Debug)]
18
+ pub struct GrpcService {
19
+ service: Arc<HeapValue<Proc>>,
20
+ adapter: Value, // Ruby CustomGrpcAdapter object
21
+ }
22
+
23
+ impl GrpcService {
24
+ pub fn from_value(params: HeapVal) -> magnus::error::Result<Self> {
25
+ let service = params.funcall::<_, _, Proc>(Symbol::new("[]"), ("service_proc",))?;
26
+ let adapter = params.funcall::<_, _, Value>(Symbol::new("[]"), ("adapter",))?;
27
+ Ok(GrpcService {
28
+ service: Arc::new(service.into()),
29
+ adapter,
30
+ })
31
+ }
32
+ }
33
+
34
+ #[async_trait]
35
+ impl MiddlewareLayer for GrpcService {
36
+ async fn before(
37
+ &self,
38
+ req: HttpRequest,
39
+ context: &mut RequestContext,
40
+ ) -> Result<Either<HttpRequest, HttpResponse>> {
41
+ // Extract gRPC method and service names from the path
42
+ let path = req.uri().path();
43
+ let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
44
+
45
+ if parts.len() < 2 {
46
+ return Ok(Either::Right(HttpResponse::new(StatusCode::BAD_REQUEST)));
47
+ }
48
+
49
+ let service_name = parts[0].to_string();
50
+ let method_name = parts[1].to_string();
51
+
52
+ // Get RPC descriptor from the adapter
53
+ let rpc_desc = self.adapter.funcall::<_, _, Option<Value>>(
54
+ "get_rpc_desc",
55
+ (service_name.clone(), method_name.clone()),
56
+ )?;
57
+
58
+ // Create gRPC request and process it
59
+ let (grpc_req, _) = ItsiGrpcRequest::new(
60
+ req,
61
+ context,
62
+ method_name,
63
+ service_name,
64
+ rpc_desc,
65
+ ).await;
66
+
67
+ grpc_req
68
+ .process(context.ruby(), self.service.clone())
69
+ .map_err(|e| e.into())
70
+ .map(|_| Either::Right(HttpResponse::new(StatusCode::OK)))
71
+ }
72
+ }
@@ -0,0 +1,85 @@
1
+ use http::{header::GetAll, HeaderValue};
2
+
3
+ /// Given a list of header values (which may be comma-separated and may have quality parameters)
4
+ /// and a list of supported items (each supported item is a full value or a prefix ending with '*'),
5
+ /// return Some(supported_item) for the first supported item that matches any header value, or None.
6
+ pub fn find_first_supported<'a, I>(
7
+ header_values: &http::header::GetAll<http::HeaderValue>,
8
+ supported: I,
9
+ ) -> Option<&'a str>
10
+ where
11
+ I: IntoIterator<Item = &'a str> + Clone,
12
+ {
13
+ // best candidate: (quality, supported_index, candidate)
14
+ let mut best: Option<(f32, usize, &'a str)> = None;
15
+
16
+ for value in header_values.iter() {
17
+ if let Ok(s) = value.to_str() {
18
+ for token in s.split(',') {
19
+ let token = token.trim();
20
+ if token.is_empty() {
21
+ continue;
22
+ }
23
+ let mut parts = token.split(';');
24
+ let enc = parts.next()?.trim();
25
+ if enc.is_empty() {
26
+ continue;
27
+ }
28
+ let quality = parts
29
+ .find_map(|p| {
30
+ let p = p.trim();
31
+ if let Some(q_str) = p.strip_prefix("q=") {
32
+ q_str.parse::<f32>().ok()
33
+ } else {
34
+ None
35
+ }
36
+ })
37
+ .unwrap_or(1.0);
38
+
39
+ // For each supported encoding, iterate over a clone of the iterable.
40
+ for (i, supp) in supported.clone().into_iter().enumerate() {
41
+ let is_match = if supp == "*" {
42
+ true
43
+ } else if let Some(prefix) = supp.strip_suffix('*') {
44
+ enc.starts_with(prefix)
45
+ } else {
46
+ enc.eq_ignore_ascii_case(supp)
47
+ };
48
+
49
+ if is_match {
50
+ best = match best {
51
+ Some((best_q, best_idx, _))
52
+ if quality > best_q || (quality == best_q && i < best_idx) =>
53
+ {
54
+ Some((quality, i, supp))
55
+ }
56
+ None => Some((quality, i, supp)),
57
+ _ => best,
58
+ };
59
+ }
60
+ }
61
+ }
62
+ }
63
+ }
64
+
65
+ best.map(|(_, _, candidate)| candidate)
66
+ }
67
+
68
+ pub fn header_contains(header_values: &GetAll<HeaderValue>, needle: &str) -> bool {
69
+ if needle == "*" {
70
+ return true;
71
+ }
72
+ let mut headers = header_values
73
+ .iter()
74
+ .flat_map(|value| value.to_str().unwrap_or("").split(','))
75
+ .map(|s| s.trim().split(';').next().unwrap_or(""))
76
+ .filter(|s| !s.is_empty());
77
+
78
+ let needle_lower = needle;
79
+ if needle.ends_with('*') {
80
+ let prefix = &needle_lower[..needle_lower.len() - 1];
81
+ headers.any(|h| h.starts_with(prefix))
82
+ } else {
83
+ headers.any(|h| h == needle_lower)
84
+ }
85
+ }
@@ -0,0 +1,195 @@
1
+ use super::{ErrorResponse, FromValue, MiddlewareLayer};
2
+ use crate::server::{
3
+ itsi_service::RequestContext,
4
+ rate_limiter::{get_ban_manager, get_rate_limiter, BanManager, RateLimiter, RateLimiterConfig},
5
+ types::{HttpRequest, HttpResponse, RequestExt},
6
+ };
7
+ use async_trait::async_trait;
8
+ use either::Either;
9
+ use itsi_tracing::*;
10
+ use magnus::error::Result;
11
+ use regex::RegexSet;
12
+ use serde::Deserialize;
13
+ use std::time::Duration;
14
+ use std::{
15
+ collections::HashMap,
16
+ sync::{Arc, OnceLock},
17
+ };
18
+
19
+ #[derive(Debug, Clone, Deserialize)]
20
+ pub struct IntrusionProtection {
21
+ #[serde(skip_deserializing)]
22
+ pub banned_url_pattern_matcher: OnceLock<RegexSet>,
23
+ #[serde(default)]
24
+ pub banned_url_patterns: Vec<String>,
25
+ #[serde(skip_deserializing)]
26
+ pub banned_header_pattern_matchers: OnceLock<HashMap<String, RegexSet>>,
27
+ #[serde(default)]
28
+ pub banned_header_patterns: HashMap<String, Vec<String>>,
29
+ pub banned_time_seconds: u64,
30
+ #[serde(skip_deserializing)]
31
+ pub rate_limiter: OnceLock<Arc<dyn RateLimiter>>,
32
+ #[serde(skip_deserializing)]
33
+ pub ban_manager: OnceLock<BanManager>,
34
+ pub store_config: RateLimiterConfig,
35
+ pub error_response: ErrorResponse,
36
+ }
37
+
38
+ #[async_trait]
39
+ impl MiddlewareLayer for IntrusionProtection {
40
+ async fn initialize(&self) -> Result<()> {
41
+ // Initialize regex matchers for URL patterns
42
+ if !self.banned_url_patterns.is_empty() {
43
+ match RegexSet::new(&self.banned_url_patterns) {
44
+ Ok(regex_set) => {
45
+ let _ = self.banned_url_pattern_matcher.set(regex_set);
46
+ }
47
+ Err(e) => {
48
+ error!("Failed to compile URL regex patterns: {:?}", e);
49
+ }
50
+ }
51
+ }
52
+
53
+ // Initialize regex matchers for header patterns
54
+ if !self.banned_header_patterns.is_empty() {
55
+ let mut header_matchers = HashMap::new();
56
+ for (header_name, patterns) in &self.banned_header_patterns {
57
+ if !patterns.is_empty() {
58
+ match RegexSet::new(patterns) {
59
+ Ok(regex_set) => {
60
+ header_matchers.insert(header_name.clone(), regex_set);
61
+ }
62
+ Err(e) => {
63
+ error!(
64
+ "Failed to compile header regex patterns for {}: {:?}",
65
+ header_name, e
66
+ );
67
+ }
68
+ }
69
+ }
70
+ }
71
+ let _ = self.banned_header_pattern_matchers.set(header_matchers);
72
+ }
73
+
74
+ // Initialize rate limiter (used for tracking bans)
75
+ // This will automatically fall back to in-memory if Redis fails
76
+ if let Ok(limiter) = get_rate_limiter(&self.store_config).await {
77
+ let _ = self.rate_limiter.set(limiter);
78
+ }
79
+
80
+ // Initialize ban manager
81
+ // This will automatically fall back to in-memory if Redis fails
82
+ if let Ok(manager) = get_ban_manager(&self.store_config).await {
83
+ let _ = self.ban_manager.set(manager);
84
+ }
85
+
86
+ Ok(())
87
+ }
88
+
89
+ async fn before(
90
+ &self,
91
+ req: HttpRequest,
92
+ context: &mut RequestContext,
93
+ ) -> Result<Either<HttpRequest, HttpResponse>> {
94
+ // Get client IP address from context's service
95
+ let client_ip = &context.addr;
96
+
97
+ // Check if the IP is already banned
98
+ if let Some(ban_manager) = self.ban_manager.get() {
99
+ match ban_manager.is_banned(client_ip).await {
100
+ Ok(Some(reason)) => {
101
+ info!("Request from banned IP {}: {}", client_ip, reason);
102
+ return Ok(Either::Right(
103
+ self.error_response.to_http_response(&req).await,
104
+ ));
105
+ }
106
+ Err(e) => {
107
+ error!("Error checking IP ban status: {:?}", e);
108
+ // Continue processing - fail open
109
+ }
110
+ _ => {
111
+ // Not banned, continue with intrusion checks
112
+ }
113
+ }
114
+ } else {
115
+ warn!("No ban manager available for intrusion protection");
116
+ }
117
+
118
+ // Check for banned URL patterns
119
+ if let Some(url_matcher) = self.banned_url_pattern_matcher.get() {
120
+ let path = req.uri().path_and_query().map(|p| p.as_str()).unwrap_or("");
121
+ info!("Checking URL pattern match for {}", path);
122
+ if url_matcher.is_match(path) {
123
+ info!("Intrusion detected: URL pattern match for {}", path);
124
+
125
+ // Ban the IP address if possible
126
+ if let Some(ban_manager) = self.ban_manager.get() {
127
+ match ban_manager
128
+ .ban_ip(
129
+ client_ip,
130
+ &format!("Banned URL pattern detected: {}", path),
131
+ Duration::from_secs(self.banned_time_seconds),
132
+ )
133
+ .await
134
+ {
135
+ Ok(_) => info!(
136
+ "Successfully banned IP {} for {} seconds",
137
+ client_ip, self.banned_time_seconds
138
+ ),
139
+ Err(e) => error!("Failed to ban IP {}: {:?}", client_ip, e),
140
+ }
141
+ }
142
+
143
+ // Always return the error response even if banning failed
144
+ return Ok(Either::Right(
145
+ self.error_response.to_http_response(&req).await,
146
+ ));
147
+ }
148
+ }
149
+
150
+ // Check for banned header patterns
151
+ if let Some(header_matchers) = self.banned_header_pattern_matchers.get() {
152
+ for (header_name, pattern_set) in header_matchers {
153
+ if let Some(header_value) = req.header(header_name) {
154
+ if pattern_set.is_match(header_value) {
155
+ info!(
156
+ "Intrusion detected: Header pattern match for {} in header {}",
157
+ header_value, header_name
158
+ );
159
+
160
+ // Ban the IP address if possible
161
+ if let Some(ban_manager) = self.ban_manager.get() {
162
+ match ban_manager
163
+ .ban_ip(
164
+ client_ip,
165
+ &format!(
166
+ "Banned header pattern detected: {} in {}",
167
+ header_value, header_name
168
+ ),
169
+ Duration::from_secs(self.banned_time_seconds),
170
+ )
171
+ .await
172
+ {
173
+ Ok(_) => info!(
174
+ "Successfully banned IP {} for {} seconds",
175
+ client_ip, self.banned_time_seconds
176
+ ),
177
+ Err(e) => error!("Failed to ban IP {}: {:?}", client_ip, e),
178
+ }
179
+ }
180
+
181
+ // Always return the error response even if banning failed
182
+ return Ok(Either::Right(
183
+ self.error_response.to_http_response(&req).await,
184
+ ));
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ // No intrusion detected
191
+ Ok(Either::Left(req))
192
+ }
193
+ }
194
+
195
+ impl FromValue for IntrusionProtection {}