diff --git a/Cargo.lock b/Cargo.lock index 286a7d7..4ac8368 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1362,6 +1362,17 @@ dependencies = [ "hashbrown 0.15.4", ] +[[package]] +name = "io-uring" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1553,7 +1564,7 @@ checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] name = "mensa-upb-api" -version = "0.2.0" +version = "0.3.0" dependencies = [ "actix-cors", "actix-governor", @@ -2866,17 +2877,19 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.45.1" +version = "1.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +checksum = "1140bb80481756a8cbe10541f37433b459c5aa1e727b4c020fbfebdc25bf3ec4" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", + "slab", "socket2", "tokio-macros", "windows-sys 0.52.0", diff --git a/Cargo.toml b/Cargo.toml index f7f10f6..ba7f95a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,6 @@ dotenvy = "0.15.7" itertools = "0.14.0" sqlx = "0.8.2" strum = "0.27.1" -tokio = "1.41.1" +tokio = "1.46.0" tracing = "0.1.40" tracing-subscriber = "0.3.18" \ No newline at end of file diff --git a/web-api/Cargo.toml b/web-api/Cargo.toml index 3bed0ad..828af51 100644 --- a/web-api/Cargo.toml +++ b/web-api/Cargo.toml @@ -5,7 +5,7 @@ license.workspace = true authors.workspace = true repository.workspace = true readme.workspace = true -version = "0.2.0" +version = "0.3.0" edition = "2021" publish = false diff --git a/web-api/src/canteen.rs b/web-api/src/canteen.rs index b1a4340..ae901f6 100644 --- a/web-api/src/canteen.rs +++ b/web-api/src/canteen.rs @@ -45,7 +45,7 @@ impl FromStr for Canteen { "zm2" => Ok(Self::ZM2), "basilica" => Ok(Self::Basilica), "atrium" => Ok(Self::Atrium), - invalid => Err(format!("Invalid canteen identifier: {}", invalid)), + invalid => Err(format!("Invalid canteen identifier: {invalid}")), } } } diff --git a/web-api/src/governor.rs b/web-api/src/governor.rs new file mode 100644 index 0000000..fc572bd --- /dev/null +++ b/web-api/src/governor.rs @@ -0,0 +1,92 @@ +use std::{net::IpAddr, str::FromStr}; + +use actix_governor::{ + governor::{ + clock::{Clock, DefaultClock, QuantaInstant}, + middleware::NoOpMiddleware, + }, + GovernorConfig, GovernorConfigBuilder, KeyExtractor, SimpleKeyExtractionError, +}; +use actix_web::{ + dev::ServiceRequest, + http::{header::ContentType, StatusCode}, + HttpResponse, HttpResponseBuilder, +}; +use serde::{Deserialize, Serialize}; + +use crate::USE_X_FORWARDED_HOST; + +pub fn get_governor( + seconds_replenish: u64, + burst_size: u32, +) -> GovernorConfig> { + GovernorConfigBuilder::default() + .seconds_per_request(seconds_replenish) + .burst_size(burst_size) + .key_extractor(UserToken) + .finish() + .unwrap() +} + +#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)] +pub struct UserToken; + +impl KeyExtractor for UserToken { + type Key = IpAddr; + type KeyExtractionError = SimpleKeyExtractionError<&'static str>; + + fn name(&self) -> &'static str { + "Bearer token" + } + + fn extract(&self, req: &ServiceRequest) -> Result { + let mut ip = USE_X_FORWARDED_HOST + .then(|| { + req.headers() + .get("X-Forwarded-Host") + .and_then(|header| IpAddr::from_str(header.to_str().unwrap_or_default()).ok()) + }) + .flatten() + .or_else(|| req.peer_addr().map(|socket| socket.ip())) + .ok_or_else(|| { + Self::KeyExtractionError::new( + r#"{ "code": 500, "msg": "Could not extract peer IP address from request"}"#, + ) + .set_content_type(ContentType::json()) + .set_status_code(StatusCode::INTERNAL_SERVER_ERROR) + })?; + + // customers often get their own /56 prefix, apply rate-limiting per prefix instead of per + // address for IPv6 + if let IpAddr::V6(ipv6) = ip { + let mut octets = ipv6.octets(); + octets[7..16].fill(0); + ip = IpAddr::V6(octets.into()); + } + Ok(ip) + } + + fn exceed_rate_limit_response( + &self, + negative: &actix_governor::governor::NotUntil, + mut response: HttpResponseBuilder, + ) -> HttpResponse { + let wait_time = negative + .wait_time_from(DefaultClock::default().now()) + .as_secs(); + response.content_type(ContentType::json()) + .body( + format!( + r#"{{"code":429, "error": "TooManyRequests", "message": "Too many requests, try again after {wait_time} seconds", "after": {wait_time}}}"# + ) + ) + } + + fn whitelisted_keys(&self) -> Vec { + Vec::new() + } + + fn key_name(&self, _key: &Self::Key) -> Option { + None + } +} diff --git a/web-api/src/lib.rs b/web-api/src/lib.rs index 2d65c92..4e528eb 100644 --- a/web-api/src/lib.rs +++ b/web-api/src/lib.rs @@ -1,14 +1,22 @@ mod canteen; mod dish; pub mod endpoints; +mod governor; mod menu; -use std::{error::Error, fmt::Display}; +use std::{error::Error, fmt::Display, sync::LazyLock}; pub use canteen::Canteen; pub use dish::{Dish, DishPrices}; +pub use governor::get_governor; pub use menu::Menu; +pub(crate) static USE_X_FORWARDED_HOST: LazyLock = LazyLock::new(|| { + std::env::var("API_USE_X_FORWARDED_HOST") + .map(|val| val == "true") + .unwrap_or(false) +}); + #[derive(Debug, Clone)] struct CustomError(String); diff --git a/web-api/src/main.rs b/web-api/src/main.rs index 6fd8cf1..40329cd 100644 --- a/web-api/src/main.rs +++ b/web-api/src/main.rs @@ -1,10 +1,11 @@ use std::env; use actix_cors::Cors; -use actix_governor::{Governor, GovernorConfigBuilder}; +use actix_governor::Governor; use actix_web::{web, App, HttpServer}; use anyhow::Result; use itertools::Itertools; +use mensa_upb_api::get_governor; use sqlx::postgres::PgPoolOptions; use tracing::{debug, error, info, level_filters::LevelFilter}; use tracing_subscriber::EnvFilter; @@ -41,7 +42,7 @@ async fn main() -> Result<()> { let burst_size = env::var("API_RATE_LIMIT_BURST") .ok() .and_then(|s| s.parse::().ok()) - .unwrap_or(5); + .unwrap_or(20); let allowed_cors = env::var("API_CORS_ALLOWED") .map(|val| { @@ -52,11 +53,7 @@ async fn main() -> Result<()> { .ok() .unwrap_or_default(); - let governor_conf = GovernorConfigBuilder::default() - .seconds_per_request(seconds_replenish) - .burst_size(burst_size) - .finish() - .unwrap(); + let governor_conf = get_governor(seconds_replenish, burst_size); info!("Starting server on {}:{}", interface, port);