From baf8fbf0d944c501c66f9732a6fc0c5a83cf17dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonah=20Br=C3=BCchert?= Date: Fri, 23 Feb 2024 02:30:06 +0100 Subject: [PATCH] motis-proxy: Implement route rate limit --- ansible/roles/motis-proxy/files/Rocket.toml | 4 +- .../roles/nginx/templates/transitous.conf.j2 | 2 + motis-proxy/Cargo.lock | 52 ++++++++++++++++++ motis-proxy/Cargo.toml | 1 + motis-proxy/Rocket.toml | 1 + motis-proxy/src/main.rs | 36 +++++++++++- motis-proxy/src/rate_limit.rs | 55 +++++++++++++++++++ 7 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 motis-proxy/src/rate_limit.rs diff --git a/ansible/roles/motis-proxy/files/Rocket.toml b/ansible/roles/motis-proxy/files/Rocket.toml index 1bed31ea..fafedec2 100644 --- a/ansible/roles/motis-proxy/files/Rocket.toml +++ b/ansible/roles/motis-proxy/files/Rocket.toml @@ -2,9 +2,11 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later +[release] +ip_header = "X-Forwarded-For" + [release.proxy] # TODO: Add /address and /ppr/route once we have it set up allowed_endpoints = [ "/intermodal", "/guesser", "/railviz/get_trains", "/railviz/get_trips", "/railviz/get_station", "/lookup/schedule_info", "/gbfs/info", "/trip_to_connection" ] - diff --git a/ansible/roles/nginx/templates/transitous.conf.j2 b/ansible/roles/nginx/templates/transitous.conf.j2 index 42ed05b4..a36412c2 100644 --- a/ansible/roles/nginx/templates/transitous.conf.j2 +++ b/ansible/roles/nginx/templates/transitous.conf.j2 @@ -41,6 +41,8 @@ server { # MOTIS Proxy location /api/ { proxy_pass http://localhost:8000/; + + proxy_set_header X-Forwarded-For $remote_addr; } # API Documentation diff --git a/motis-proxy/Cargo.lock b/motis-proxy/Cargo.lock index 8a57caeb..c50c2d39 100644 --- a/motis-proxy/Cargo.lock +++ b/motis-proxy/Cargo.lock @@ -20,6 +20,18 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "ahash" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d713b3834d76b85304d4d525563c1276e2e30dc97cc67bfb4585a4a29fc2c89f" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.2" @@ -29,6 +41,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + [[package]] name = "async-stream" version = "0.3.5" @@ -477,6 +495,10 @@ name = "hashbrown" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +dependencies = [ + "ahash", + "allocator-api2", +] [[package]] name = "hermit-abi" @@ -680,6 +702,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2c024b41519440580066ba82aab04092b333e09066a5eb86c7c4890df31f22" +dependencies = [ + "hashbrown 0.14.3", +] + [[package]] name = "matchers" version = "0.1.0" @@ -726,6 +757,7 @@ name = "motis-proxy" version = "0.1.0" dependencies = [ "log", + "lru", "reqwest", "rocket", "rocket_cors", @@ -2166,3 +2198,23 @@ checksum = "1367295b8f788d371ce2dbc842c7b709c73ee1364d30351dd300ec2203b12377" dependencies = [ "is-terminal", ] + +[[package]] +name = "zerocopy" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.50", +] diff --git a/motis-proxy/Cargo.toml b/motis-proxy/Cargo.toml index dcaac8dd..b08fcbcf 100644 --- a/motis-proxy/Cargo.toml +++ b/motis-proxy/Cargo.toml @@ -18,3 +18,4 @@ rocket_cors = { version = "0.6.0", default-features = false } serde_default = "0.1" log = "0.4" rocket_okapi = { version = "0.8", features = [ "rapidoc" ] } +lru = "0.12" diff --git a/motis-proxy/Rocket.toml b/motis-proxy/Rocket.toml index bcc0a912..acc6f94e 100644 --- a/motis-proxy/Rocket.toml +++ b/motis-proxy/Rocket.toml @@ -15,3 +15,4 @@ allowed_endpoints = [ "/intermodal", "/guesser", "/address", [debug.proxy] proxy_assets = true motis_address = "https://europe.motis-project.de" +routes_per_minute_limit = 20 diff --git a/motis-proxy/src/main.rs b/motis-proxy/src/main.rs index 4f015481..b03888fc 100644 --- a/motis-proxy/src/main.rs +++ b/motis-proxy/src/main.rs @@ -5,6 +5,8 @@ #[macro_use] extern crate rocket; +mod rate_limit; + use log::{trace, warn}; use reqwest::{Client, StatusCode}; use rocket::{ @@ -18,7 +20,9 @@ use rocket_cors::{AllowedOrigins, CorsOptions}; use serde::{Deserialize, Serialize}; use serde_default::DefaultFromSerde; -use std::time::Duration; +use std::{net::IpAddr, num::NonZeroUsize, time::Duration}; + +use rate_limit::IpRateLimit; // For documetation generation use rocket_okapi::okapi::schemars; @@ -43,6 +47,12 @@ fn default_proxy_assets() -> bool { fn default_allowed_endpoints() -> Option> { None } +fn default_routes_per_minute_limit() -> u16 { + 20 +} +fn default_lru_rate_limit_entries() -> usize { + 10_000 +} #[derive(Deserialize, DefaultFromSerde)] struct Config { @@ -62,6 +72,12 @@ struct Config { /// If this option is not set, all known endpoints will be allowed. #[serde(default = "default_allowed_endpoints")] allowed_endpoints: Option>, + + #[serde(default = "default_routes_per_minute_limit")] + routes_per_minute_limit: u16, + + #[serde(default = "default_lru_rate_limit_entries")] + lru_rate_limit_entries: usize, } #[derive(Deserialize, Serialize, PartialEq, Eq, JsonSchema)] @@ -417,6 +433,8 @@ async fn proxy_api( request: Json, http_client: &State, config: &State, + route_rate_limit: &State, + remote_address: IpAddr, ) -> ResultResponse>> { let request = request.into_inner(); @@ -431,6 +449,17 @@ async fn proxy_api( } } + // Check if routing limit was exceeded + if matches!( + request.content, + RequestContent::IntermodalConnectionRequest(_) + | RequestContent::IntermodalRoutingRequest(_) + ) { + if route_rate_limit.should_limit(&remote_address) { + return Err(Custom(Status::TooManyRequests, ())) + } + } + trace!("MOTIS Request <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<"); trace!("{}", serde_json::to_string_pretty(&request).unwrap()); @@ -548,6 +577,11 @@ fn rocket() -> _ { ) .attach(cors.clone()) .manage(cors) + .manage(IpRateLimit::new( + NonZeroUsize::new(config.lru_rate_limit_entries) + .expect("lru_rate_limit_entries must not be zero"), + config.routes_per_minute_limit + )) .manage(config) .mount("/", routes) .mount("/", rocket_cors::catch_all_options_routes()) diff --git a/motis-proxy/src/rate_limit.rs b/motis-proxy/src/rate_limit.rs new file mode 100644 index 00000000..e84505bf --- /dev/null +++ b/motis-proxy/src/rate_limit.rs @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2024 Jonah BrĂ¼chert +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use lru::LruCache; +use std::{net::IpAddr, num::NonZeroUsize, time::Duration, sync::{Arc, Mutex}}; + +use std::time::Instant; + +struct Inner { + requests: LruCache, + rate_limit: u16, + last_cleared: Instant, +} + +pub struct IpRateLimit { + inner: Arc> +} + +impl IpRateLimit { + pub fn new(size: NonZeroUsize, rate_limit: u16) -> IpRateLimit { + IpRateLimit { + inner: Arc::new(Mutex::new(Inner { + requests: LruCache::new(size), + rate_limit, + last_cleared: Instant::now(), + })) + } + } + + pub fn should_limit(&self, ip: &IpAddr) -> bool { + let mut inner = self.inner.lock().unwrap(); + + // Check if data is older than a minute, then delete it + if inner.last_cleared.elapsed() >= Duration::from_secs(60) { + inner.requests.clear(); + inner.last_cleared = Instant::now(); + } + + // Incerement existing request counter for address or create a new one + if let Some(count) = inner.requests.get_mut(ip) { + *count += 1; + } else { + inner.requests.put(*ip, 1); + } + + + // Check if ip has reached rate limit + let rate_limit = inner.rate_limit; + inner.requests + .get(&ip) + .map(|count| *count >= rate_limit) + .unwrap_or(false) + } +}