Per path rate limiter

This commit is contained in:
Ara Sadoyan
2025-07-24 13:34:15 +02:00
parent 6f012cee69
commit 2ad3a059ab
9 changed files with 114 additions and 57 deletions

View File

@@ -16,6 +16,8 @@ Built on Rust, on top of **Cloudflares Pingora engine**, **Aralez** delivers
- **Automatic load of certificates** — Automatically reads and loads certificates from a folder, without a restart. - **Automatic load of certificates** — Automatically reads and loads certificates from a folder, without a restart.
- **Upstreams TLS detection** — Aralez will automatically detect if upstreams uses secure connection. - **Upstreams TLS detection** — Aralez will automatically detect if upstreams uses secure connection.
- **Built in rate limiter** — Limit requests to server, by setting up upper limit for requests per seconds, per virtualhost. - **Built in rate limiter** — Limit requests to server, by setting up upper limit for requests per seconds, per virtualhost.
- **Global rate limiter** — Set rate limit for all virtualhosts.
- **Per path rate limiter** — Set rate limit for specific paths. Path limits will override global limits.
- **Authentication** — Supports Basic Auth, API tokens, and JWT verification. - **Authentication** — Supports Basic Auth, API tokens, and JWT verification.
- **Basic Auth** - **Basic Auth**
- **API Key** via `x-api-key` header - **API Key** via `x-api-key` header
@@ -177,6 +179,7 @@ authorization:
myhost.mydomain.com: myhost.mydomain.com:
paths: paths:
"/": "/":
rate_limit: 20
to_https: false to_https: false
headers: headers:
- "X-Some-Thing:Yaaaaaaaaaaaaaaa" - "X-Some-Thing:Yaaaaaaaaaaaaaaa"
@@ -201,6 +204,7 @@ myhost.mydomain.com:
- Requests limits are calculated per requester ip plus requested virtualhost. - Requests limits are calculated per requester ip plus requested virtualhost.
- If the requester exceeds the limit it will receive `429 Too Many Requests` error. - If the requester exceeds the limit it will receive `429 Too Many Requests` error.
- Optional. Rate limiter will be disabled if the parameter is entirely removed from config. - Optional. Rate limiter will be disabled if the parameter is entirely removed from config.
- Requests to `myhost.mydomain.com/` will be limited to 20 requests per second.
- Requests to `myhost.mydomain.com/` will be proxied to `127.0.0.1` and `127.0.0.2`. - Requests to `myhost.mydomain.com/` will be proxied to `127.0.0.1` and `127.0.0.2`.
- Plain HTTP to `myhost.mydomain.com/foo` will get 301 redirect to configured TLS port of Aralez. - Plain HTTP to `myhost.mydomain.com/foo` will get 301 redirect to configured TLS port of Aralez.
- Requests to `myhost.mydomain.com/foo` will be proxied to `127.0.0.4` and `127.0.0.5`. - Requests to `myhost.mydomain.com/foo` will be proxied to `127.0.0.4` and `127.0.0.5`.

View File

@@ -1,7 +1,7 @@
# Main configuration file , applied on startup # Main configuration file, applied on startup
threads: 12 # Nubber of daemon threads default setting threads: 12 # Number of daemon threads default setting
#user: aralez # Username for running aralez after dropping root privileges, requires program to start as root #user: pastor # Username for running aralez after dropping root privileges, requires program to start as root
#group: aralez # Group for running aralez after dropping root privileges, requires program to start as root #group: pastor # Group for running aralez after dropping root privileges, requires program to start as root
daemon: false # Run in background daemon: false # Run in background
upstream_keepalive_pool_size: 500 # Pool size for upstream keepalive connections upstream_keepalive_pool_size: 500 # Pool size for upstream keepalive connections
pid_file: /tmp/aralez.pid # Path to PID file pid_file: /tmp/aralez.pid # Path to PID file
@@ -10,14 +10,14 @@ upgrade_sock: /tmp/aralez.sock # Path to socket file
config_api_enabled: true # Boolean to enable/disable remote config push capability. config_api_enabled: true # Boolean to enable/disable remote config push capability.
config_address: 0.0.0.0:3000 # HTTP API address for pushing upstreams.yaml from remote location config_address: 0.0.0.0:3000 # HTTP API address for pushing upstreams.yaml from remote location
config_tls_address: 0.0.0.0:3001 # HTTP TLS API address for pushing upstreams.yaml from remote location config_tls_address: 0.0.0.0:3001 # HTTP TLS API address for pushing upstreams.yaml from remote location
config_tls_certificate: etc/server.crt # Mandatory if config_tls_address is set config_tls_certificate: /opt/Rust/Projects/asyncweb/etc/server.crt # Mandatory if config_tls_address is set
config_tls_key_file: etc/key.pem # Mandatory if config_tls_address is set config_tls_key_file: /opt/Rust/Projects/asyncweb/etc/key.pem # Mandatory if config_tls_address is set
proxy_address_http: 0.0.0.0:6193 # Proxy HTTP bind address proxy_address_http: 0.0.0.0:6193 # Proxy HTTP bind address
proxy_address_tls: 0.0.0.0:6194 # Optional, Proxy TLS bind address proxy_address_tls: 0.0.0.0:6194 # Optional, Proxy TLS bind address
proxy_certificates: etc/certificates # Mandatory if proxy_address_tls set, should contain certificate and key files strictly in a format {NAME}.crt, {NAME}.key. proxy_certificates: /opt/Rust/Projects/asyncweb/etc/yoyo # Mandatory if proxy_address_tls set, should contain certificate and key files strictly in a format {NAME}.crt, {NAME}.key.
upstreams_conf: etc/upstreams.yaml # the location of upstreams file upstreams_conf: /opt/Rust/Projects/asyncweb/etc/upstreams.yaml # the location of upstreams file
#file_server_folder: /some/path # Optional, local folder to serve file_server_folder: /opt/storage # Optional, local folder to serve
#file_server_address: 127.0.0.1:3002 # Optional, Local address for file server. Can set as upstream for public access. file_server_address: 127.0.0.1:3002 # Optional, Local address for file server. Can set as upstream for public access.
log_level: info # info, warn, error, debug, trace, off log_level: info # info, warn, error, debug, trace, off
hc_method: HEAD # Healthcheck method (HEAD, GET, POST are supported) UPPERCASE hc_method: HEAD # Healthcheck method (HEAD, GET, POST are supported) UPPERCASE
hc_interval: 2 #Interval for health checks in seconds hc_interval: 2 #Interval for health checks in seconds

View File

@@ -17,9 +17,9 @@ authorization:
# creds: "5ecbf799-1343-4e94-a9b5-e278af5cd313-56b45249-1839-4008-a450-a60dc76d2bae" # creds: "5ecbf799-1343-4e94-a9b5-e278af5cd313-56b45249-1839-4008-a450-a60dc76d2bae"
consul: # If the provider is consul. Otherwise, ignored. consul: # If the provider is consul. Otherwise, ignored.
servers: servers:
- "http://master1:8500" - "http://consul1:8500"
- "http://192.168.22.1:8500" - "http://consul2:8500"
- "http://master1.foo.local:8500" - "http://consul3:8500"
services: # proxy: The hostname to access the proxy server, real : The real service name in Consul database. services: # proxy: The hostname to access the proxy server, real : The real service name in Consul database.
- proxy: "proxy-frontend-dev-frontend-srv" - proxy: "proxy-frontend-dev-frontend-srv"
real: "frontend-dev-frontend-srv" real: "frontend-dev-frontend-srv"
@@ -27,10 +27,11 @@ consul: # If the provider is consul. Otherwise, ignored.
upstreams: upstreams:
myip.mydomain.com: myip.mydomain.com:
paths: paths:
rate_limit: 10 # Per path rate limit have higher priority than global rate limit. If not set, the global rate limit will be used
"/": "/":
to_https: false to_https: false
headers: headers:
- "X-Proxy-From:Gazan" - "X-Proxy-From:Aralez"
servers: # List of upstreams HOST:PORT servers: # List of upstreams HOST:PORT
- "127.0.0.1:8000" - "127.0.0.1:8000"
- "127.0.0.2:8000" - "127.0.0.2:8000"
@@ -40,7 +41,7 @@ upstreams:
to_https: true to_https: true
headers: headers:
- "X-Some-Thing:Yaaaaaaaaaaaaaaa" - "X-Some-Thing:Yaaaaaaaaaaaaaaa"
- "X-Proxy-From:Gazan" - "X-Proxy-From:Aralez"
servers: servers:
- "127.0.0.1:8000" - "127.0.0.1:8000"
- "127.0.0.2:8000" - "127.0.0.2:8000"

View File

@@ -133,6 +133,7 @@ async fn get_by_http(url: String, token: Option<String>) -> Option<DashMap<Strin
is_ssl: false, is_ssl: false,
is_http2: false, is_http2: false,
to_https: false, to_https: false,
rate_limit: None,
}; };
values.push(to_add); values.push(to_add);
} }

View File

@@ -44,6 +44,7 @@ pub async fn hc2(upslist: Arc<UpstreamsDashMap>, fullist: Arc<UpstreamsDashMap>,
is_ssl: tls.0, is_ssl: tls.0,
is_http2: is_h2, is_http2: is_h2,
to_https: k.1.to_https, to_https: k.1.to_https,
rate_limit: k.1.rate_limit,
}; };
let resp = http_request(_link.as_str(), params.0, "", &client).await; let resp = http_request(_link.as_str(), params.0, "", &client).await;
match resp.0 { match resp.0 {
@@ -55,6 +56,7 @@ pub async fn hc2(upslist: Arc<UpstreamsDashMap>, fullist: Arc<UpstreamsDashMap>,
is_ssl: tls.0, is_ssl: tls.0,
is_http2: is_h2, is_http2: is_h2,
to_https: k.1.to_https, to_https: k.1.to_https,
rate_limit: k.1.rate_limit,
}; };
} }
innervec.push(scheme); innervec.push(scheme);

View File

@@ -80,6 +80,10 @@ fn populate_headers_and_auth(config: &mut Configuration, parsed: &Config) {
config.extraparams.to_https = parsed.to_https; config.extraparams.to_https = parsed.to_https;
config.extraparams.rate_limit = parsed.rate_limit; config.extraparams.rate_limit = parsed.rate_limit;
if let Some(rate) = &parsed.rate_limit {
info!("Applied Global Rate Limit : {} request per second", rate);
}
if let Some(auth) = &parsed.authorization { if let Some(auth) = &parsed.authorization {
let name = auth.get("type").unwrap_or(&"".to_string()).to_string(); let name = auth.get("type").unwrap_or(&"".to_string()).to_string();
let creds = auth.get("creds").unwrap_or(&"".to_string()).to_string(); let creds = auth.get("creds").unwrap_or(&"".to_string()).to_string();
@@ -94,8 +98,11 @@ fn populate_file_upstreams(config: &mut Configuration, parsed: &Config) {
for (hostname, host_config) in upstreams { for (hostname, host_config) in upstreams {
let path_map = DashMap::new(); let path_map = DashMap::new();
let header_list = DashMap::new(); let header_list = DashMap::new();
for (path, path_config) in &host_config.paths { for (path, path_config) in &host_config.paths {
if let Some(rate) = &path_config.rate_limit {
info!("Applied Rate Limit for {} : {} request per second", hostname, rate);
}
let mut server_list = Vec::new(); let mut server_list = Vec::new();
let mut hl = Vec::new(); let mut hl = Vec::new();
@@ -109,6 +116,14 @@ fn populate_file_upstreams(config: &mut Configuration, parsed: &Config) {
header_list.insert(path.clone(), hl); header_list.insert(path.clone(), hl);
for server in &path_config.servers { for server in &path_config.servers {
// let mut rate: Option<isize> = None;
// let size: isize = path_config.servers.len() as isize;
// if let Some(limit) = &path_config.rate_limit {
// if size > 0 {
// rate = Some(limit / size);
// }
// }
if let Some((ip, port_str)) = server.split_once(':') { if let Some((ip, port_str)) = server.split_once(':') {
if let Ok(port) = port_str.parse::<u16>() { if let Ok(port) = port_str.parse::<u16>() {
server_list.push(InnerMap { server_list.push(InnerMap {
@@ -117,6 +132,8 @@ fn populate_file_upstreams(config: &mut Configuration, parsed: &Config) {
is_ssl: true, is_ssl: true,
is_http2: false, is_http2: false,
to_https: path_config.to_https.unwrap_or(false), to_https: path_config.to_https.unwrap_or(false),
// rate_limit: rate,
rate_limit: path_config.rate_limit,
}); });
} }
} }

View File

@@ -5,6 +5,23 @@ use std::sync::atomic::AtomicUsize;
// pub type InnerMap = BackendConfig; // pub type InnerMap = BackendConfig;
pub type UpstreamsDashMap = DashMap<String, DashMap<String, (Vec<InnerMap>, AtomicUsize)>>; pub type UpstreamsDashMap = DashMap<String, DashMap<String, (Vec<InnerMap>, AtomicUsize)>>;
// #[derive(Debug, Default)]
// pub struct UpstreamsMap {
// pub upstreams: DashMap<String, DashMap<String, (Vec<InnerMap>, AtomicUsize)>>,
// pub ratelimit: DashMap<String, Option<isize>>,
// }
// impl UpstreamsMap {
// pub fn new() -> Self {
// Self {
// upstreams: Default::default(),
// ratelimit: Default::default(),
// }
// }
// }
//
// pub type XUpstreamsDashMap = DashMap<String, UpstreamsMap>;
pub type UpstreamsIdMap = DashMap<String, InnerMap>; pub type UpstreamsIdMap = DashMap<String, InnerMap>;
pub type Headers = DashMap<String, DashMap<String, Vec<(String, String)>>>; pub type Headers = DashMap<String, DashMap<String, Vec<(String, String)>>>;
@@ -97,6 +114,7 @@ pub struct InnerMap {
pub is_ssl: bool, pub is_ssl: bool,
pub is_http2: bool, pub is_http2: bool,
pub to_https: bool, pub to_https: bool,
pub rate_limit: Option<isize>,
} }
impl InnerMap { impl InnerMap {
@@ -107,6 +125,7 @@ impl InnerMap {
is_ssl: Default::default(), is_ssl: Default::default(),
is_http2: Default::default(), is_http2: Default::default(),
to_https: Default::default(), to_https: Default::default(),
rate_limit: Default::default(),
} }
} }
} }

View File

@@ -154,6 +154,7 @@ pub fn clone_idmap_into(original: &UpstreamsDashMap, cloned: &UpstreamsIdMap) {
is_ssl: false, is_ssl: false,
is_http2: false, is_http2: false,
to_https: false, to_https: false,
rate_limit: None,
}; };
cloned.insert(id, to_add); cloned.insert(id, to_add);
cloned.insert(hh, x.to_owned()); cloned.insert(hh, x.to_owned());

View File

@@ -1,6 +1,6 @@
use crate::utils::auth::authenticate; use crate::utils::auth::authenticate;
use crate::utils::metrics::*; use crate::utils::metrics::*;
use crate::utils::structs::{AppConfig, Extraparams, Headers, UpstreamsDashMap, UpstreamsIdMap}; use crate::utils::structs::{AppConfig, Extraparams, Headers, InnerMap, UpstreamsDashMap, UpstreamsIdMap};
use crate::web::gethosts::GetHost; use crate::web::gethosts::GetHost;
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use async_trait::async_trait; use async_trait::async_trait;
@@ -36,6 +36,8 @@ pub struct Context {
redirect_to: String, redirect_to: String,
start_time: Instant, start_time: Instant,
hostname: Option<String>, hostname: Option<String>,
upstream_peer: Option<InnerMap>,
extraparams: arc_swap::Guard<Arc<Extraparams>>,
} }
#[async_trait] #[async_trait]
@@ -48,14 +50,18 @@ impl ProxyHttp for LB {
redirect_to: String::new(), redirect_to: String::new(),
start_time: Instant::now(), start_time: Instant::now(),
hostname: None, hostname: None,
upstream_peer: None,
extraparams: self.extraparams.load(),
} }
} }
async fn request_filter(&self, session: &mut Session, _ctx: &mut Self::CTX) -> Result<bool> { async fn request_filter(&self, session: &mut Session, _ctx: &mut Self::CTX) -> Result<bool> {
if let Some(auth) = self.extraparams.load().authentication.get("authorization") { let ep = _ctx.extraparams.clone();
if let Some(auth) = ep.authentication.get("authorization") {
let authenticated = authenticate(&auth.value(), &session); let authenticated = authenticate(&auth.value(), &session);
if !authenticated { if !authenticated {
let _ = session.respond_error(401).await; let _ = session.respond_error(401).await;
warn!("Forbidden: {:?}, {}", session.client_addr(), session.req_header().uri.path().to_string()); warn!("Forbidden: {:?}, {}", session.client_addr(), session.req_header().uri.path());
return Ok(true); return Ok(true);
} }
}; };
@@ -63,33 +69,9 @@ impl ProxyHttp for LB {
let hostname = return_header_host(&session); let hostname = return_header_host(&session);
_ctx.hostname = hostname.clone(); _ctx.hostname = hostname.clone();
if let Some(rate) = self.extraparams.load().rate_limit {
match hostname {
None => return Ok(false),
Some(host) => {
let curr_window_requests = RATE_LIMITER.observe(&host, 1);
if curr_window_requests > rate {
let mut header = ResponseHeader::build(429, None).unwrap();
header.insert_header("X-Rate-Limit-Limit", rate.to_string()).unwrap();
header.insert_header("X-Rate-Limit-Remaining", "0").unwrap();
header.insert_header("X-Rate-Limit-Reset", "1").unwrap();
session.set_keepalive(None);
session.write_response_header(Box::new(header), true).await?;
debug!("Rate limited: {:?}, {}", session.client_addr(), rate);
return Ok(true);
}
}
};
}
Ok(false)
}
async fn upstream_peer(&self, session: &mut Session, ctx: &mut Self::CTX) -> Result<Box<HttpPeer>> {
// let host_name = return_header_host(&session);
match ctx.hostname.as_ref() {
Some(hostname) => {
let mut backend_id = None; let mut backend_id = None;
if self.extraparams.load().sticky_sessions { if ep.sticky_sessions {
if let Some(cookies) = session.req_header().headers.get("cookie") { if let Some(cookies) = session.req_header().headers.get("cookie") {
if let Ok(cookie_str) = cookies.to_str() { if let Ok(cookie_str) = cookies.to_str() {
for cookie in cookie_str.split(';') { for cookie in cookie_str.split(';') {
@@ -103,9 +85,40 @@ impl ProxyHttp for LB {
} }
} }
let optioninnermap = self.get_host(hostname, hostname, backend_id); match hostname {
None => return Ok(false),
Some(host) => {
let optioninnermap = self.get_host(host.as_str(), host.as_str(), backend_id);
match optioninnermap { match optioninnermap {
None => return Ok(false),
Some(ref innermap) => {
if let Some(rate) = innermap.rate_limit.or(ep.rate_limit) {
// let rate_key = session.client_addr().and_then(|addr| addr.as_inet()).map(|inet| inet.ip().to_string()).unwrap_or_else(|| host.to_string());
let rate_key = session.client_addr().and_then(|addr| addr.as_inet()).map(|inet| inet.ip());
let curr_window_requests = RATE_LIMITER.observe(&rate_key, 1);
if curr_window_requests > rate {
let mut header = ResponseHeader::build(429, None).unwrap();
header.insert_header("X-Rate-Limit-Limit", rate.to_string()).unwrap();
header.insert_header("X-Rate-Limit-Remaining", "0").unwrap();
header.insert_header("X-Rate-Limit-Reset", "1").unwrap();
session.set_keepalive(None);
session.write_response_header(Box::new(header), true).await?;
debug!("Rate limited: {:?}, {}", rate_key, rate);
return Ok(true);
}
}
}
}
_ctx.upstream_peer = optioninnermap;
}
}
Ok(false)
}
async fn upstream_peer(&self, session: &mut Session, ctx: &mut Self::CTX) -> Result<Box<HttpPeer>> {
// let host_name = return_header_host(&session);
match ctx.hostname.as_ref() {
Some(hostname) => {
match ctx.upstream_peer.as_ref() {
// Some((address, port, ssl, is_h2, to_https)) => { // Some((address, port, ssl, is_h2, to_https)) => {
Some(innermap) => { Some(innermap) => {
let mut peer = Box::new(HttpPeer::new((innermap.address.clone(), innermap.port.clone()), innermap.is_ssl, String::new())); let mut peer = Box::new(HttpPeer::new((innermap.address.clone(), innermap.port.clone()), innermap.is_ssl, String::new()));
@@ -118,8 +131,7 @@ impl ProxyHttp for LB {
peer.options.verify_cert = false; peer.options.verify_cert = false;
peer.options.verify_hostname = false; peer.options.verify_hostname = false;
} }
if ctx.to_https || innermap.to_https {
if self.extraparams.load().to_https.unwrap_or(false) || innermap.to_https {
if let Some(stream) = session.stream() { if let Some(stream) = session.stream() {
if stream.get_ssl().is_none() { if stream.get_ssl().is_none() {
if let Some(addr) = session.server_addr() { if let Some(addr) = session.server_addr() {
@@ -190,7 +202,7 @@ impl ProxyHttp for LB {
// } // }
async fn response_filter(&self, session: &mut Session, _upstream_response: &mut ResponseHeader, ctx: &mut Self::CTX) -> Result<()> { async fn response_filter(&self, session: &mut Session, _upstream_response: &mut ResponseHeader, ctx: &mut Self::CTX) -> Result<()> {
// _upstream_response.insert_header("X-Proxied-From", "Fooooooooooooooo").unwrap(); // _upstream_response.insert_header("X-Proxied-From", "Fooooooooooooooo").unwrap();
if self.extraparams.load().sticky_sessions { if ctx.extraparams.sticky_sessions {
let backend_id = ctx.backend_id.clone(); let backend_id = ctx.backend_id.clone();
if let Some(bid) = self.ump_byid.get(&backend_id) { if let Some(bid) = self.ump_byid.get(&backend_id) {
let _ = _upstream_response.insert_header("set-cookie", format!("backend_id={}; Path=/; Max-Age=600; HttpOnly; SameSite=Lax", bid.address)); let _ = _upstream_response.insert_header("set-cookie", format!("backend_id={}; Path=/; Max-Age=600; HttpOnly; SameSite=Lax", bid.address));