From de72d6037f255c50ac7d326bbccc3bac462a279e Mon Sep 17 00:00:00 2001 From: Quentin Dufour Date: Wed, 29 Nov 2023 12:49:55 +0100 Subject: [PATCH 1/4] refactor configuration to support redirects --- src/cert_store.rs | 4 +- src/https.rs | 6 +- src/main.rs | 2 +- src/proxy_config.rs | 315 ++++++++++++++++++++++++++++++++------------ 4 files changed, 238 insertions(+), 89 deletions(-) diff --git a/src/cert_store.rs b/src/cert_store.rs index a8072a2..0ced178 100644 --- a/src/cert_store.rs +++ b/src/cert_store.rs @@ -78,7 +78,7 @@ impl CertStore { let proxy_config: Arc = rx_proxy_config.borrow().clone(); for ent in proxy_config.entries.iter() { - if let HostDescription::Hostname(domain) = &ent.host { + if let HostDescription::Hostname(domain) = &ent.url_prefix.host { if let Some((host, _port)) = domain.split_once(':') { domains.insert(host.to_string()); } else { @@ -121,7 +121,7 @@ impl CertStore { .borrow() .entries .iter() - .any(|ent| ent.host.matches(domain)) + .any(|ent| ent.url_prefix.host.matches(domain)) { bail!("Domain {} should not have a TLS certificate.", domain); } diff --git a/src/https.rs b/src/https.rs index ed98ae1..5f8a17e 100644 --- a/src/https.rs +++ b/src/https.rs @@ -234,8 +234,9 @@ async fn select_target_and_proxy( .iter() .filter(|ent| { ent.flags.healthy - && ent.host.matches(host) + && ent.url_prefix.host.matches(host) && ent + .url_prefix .path_prefix .as_ref() .map(|prefix| path.starts_with(prefix)) @@ -244,7 +245,8 @@ async fn select_target_and_proxy( .max_by_key(|ent| { ( ent.priority, - ent.path_prefix + ent.url_prefix + .path_prefix .as_ref() .map(|x| x.len() as i32) .unwrap_or(0), diff --git a/src/main.rs b/src/main.rs index 2e08495..93abcca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -239,7 +239,7 @@ async fn dump_config_on_change( let mut cfg_map = BTreeMap::<_, Vec<_>>::new(); for ent in cfg.entries.iter() { cfg_map - .entry((&ent.host, &ent.path_prefix)) + .entry((&ent.url_prefix.host, &ent.url_prefix.path_prefix)) .or_default() .push(ent); } diff --git a/src/proxy_config.rs b/src/proxy_config.rs index dab4d98..eebff72 100644 --- a/src/proxy_config.rs +++ b/src/proxy_config.rs @@ -13,7 +13,7 @@ use crate::consul; // ---- Extract proxy config from Consul catalog ---- -#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] pub enum HostDescription { Hostname(String), Pattern(glob::Pattern), @@ -45,12 +45,48 @@ impl std::fmt::Display for HostDescription { } } -#[derive(Debug)] -pub struct ProxyEntry { +#[derive(Debug, Clone)] +pub struct UrlPrefix { /// Publicly exposed TLS hostnames for matching this rule pub host: HostDescription, + /// Path prefix for matching this rule pub path_prefix: Option, +} + +impl PartialEq for UrlPrefix { + fn eq(&self, other: &Self) -> bool { + self.host == other.host && self.path_prefix == other.path_prefix + } +} +impl Eq for UrlPrefix {} + +impl UrlPrefix { + fn new(raw_prefix: &str) -> Option { + let (raw_host, path_prefix) = match raw_prefix.find('/') { + Some(i) => { + let (host, pp) = raw_prefix.split_at(i); + (host, Some(pp.to_string())) + } + None => (raw_prefix, None), + }; + + let host = match HostDescription::new(raw_host) { + Ok(h) => h, + Err(e) => { + warn!("Invalid hostname pattern {}: {}", raw_host, e); + return None; + } + }; + + Some(Self { host, path_prefix }) + } +} + +#[derive(Debug)] +pub struct ProxyEntry { + /// An Url prefix is made of a host and maybe a path prefix + pub url_prefix: UrlPrefix, /// Priority with which this rule is considered (highest first) pub priority: u32, @@ -68,6 +104,10 @@ pub struct ProxyEntry { /// when matching this rule pub add_headers: Vec<(String, String)>, + /// Try to match all these redirection before forwarding to the backend + /// when matching this rule + pub redirects: Vec<(UrlPrefix, UrlPrefix, u32)>, + /// Number of calls in progress, used to deprioritize slow back-ends pub calls_in_progress: atomic::AtomicI64, /// Time of last call, used for round-robin selection @@ -76,8 +116,7 @@ pub struct ProxyEntry { impl PartialEq for ProxyEntry { fn eq(&self, other: &Self) -> bool { - self.host == other.host - && self.path_prefix == other.path_prefix + self.url_prefix == other.url_prefix && self.priority == other.priority && self.service_name == other.service_name && self.target_addr == other.target_addr @@ -88,6 +127,58 @@ impl PartialEq for ProxyEntry { } impl Eq for ProxyEntry {} +impl ProxyEntry { + fn new( + service_name: String, + frontend: MatchTag, + target_addr: SocketAddr, + middleware: &[ConfigTag], + flags: ProxyEntryFlags, + ) -> Self { + let (url_prefix, priority, https_target) = match frontend { + MatchTag::Http(u, p) => (u, p, false), + MatchTag::HttpWithTls(u, p) => (u, p, true), + }; + + let mut add_headers = vec![]; + let mut redirects = vec![]; + for mid in middleware.into_iter() { + match mid { + ConfigTag::AddHeader(k, v) => add_headers.push((k.to_string(), v.to_string())), + ConfigTag::AddRedirect(m, r, c) => redirects.push(((*m).clone(), (*r).clone(), *c)), + ConfigTag::LocalLb => + /* handled in parent fx */ + { + () + } + ConfigTag::GlobalLb => + /* handled in parent fx */ + { + () + } + }; + } + + ProxyEntry { + // id + service_name, + // frontend + url_prefix, + priority, + // backend + target_addr, + https_target, + // middleware + flags, + add_headers, + redirects, + // internal + last_call: atomic::AtomicI64::from(0), + calls_in_progress: atomic::AtomicI64::from(0), + } + } +} + #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub struct ProxyEntryFlags { /// Is the target healthy? @@ -115,8 +206,8 @@ impl std::fmt::Display for ProxyEntry { write!( f, "{}{} {}", - self.host, - self.path_prefix.as_deref().unwrap_or_default(), + self.url_prefix.host, + self.url_prefix.path_prefix.as_deref().unwrap_or_default(), self.priority )?; if !self.flags.healthy { @@ -144,62 +235,103 @@ pub struct ProxyConfig { pub entries: Vec, } -fn parse_tricot_tag( - service_name: String, - tag: &str, - target_addr: SocketAddr, - add_headers: &[(String, String)], - flags: ProxyEntryFlags, -) -> Option { - let splits = tag.split(' ').collect::>(); - if (splits.len() != 2 && splits.len() != 3) - || (splits[0] != "tricot" && splits[0] != "tricot-https") - { - return None; - } - - let (host, path_prefix) = match splits[1].find('/') { - Some(i) => { - let (host, pp) = splits[1].split_at(i); - (host, Some(pp.to_string())) - } - None => (splits[1], None), - }; - - let priority = match splits.len() { - 3 => splits[2].parse().ok()?, - _ => 100, - }; - - let host = match HostDescription::new(host) { - Ok(h) => h, - Err(e) => { - warn!("Invalid hostname pattern {}: {}", host, e); - return None; - } - }; - - Some(ProxyEntry { - service_name, - target_addr, - https_target: (splits[0] == "tricot-https"), - host, - flags, - path_prefix, - priority, - add_headers: add_headers.to_vec(), - last_call: atomic::AtomicI64::from(0), - calls_in_progress: atomic::AtomicI64::from(0), - }) +#[derive(Debug)] +enum ParsedTag<'a> { + Frontend(MatchTag), + Middleware(ConfigTag<'a>), } -fn parse_tricot_add_header_tag(tag: &str) -> Option<(String, String)> { - let splits = tag.splitn(3, ' ').collect::>(); - if splits.len() == 3 && splits[0] == "tricot-add-header" { - Some((splits[1].to_string(), splits[2].to_string())) - } else { - None - } +#[derive(Debug)] +enum MatchTag { + /// HTTP backend (plain text) + Http(UrlPrefix, u32), + /// HTTPS backend (TLS encrypted) + HttpWithTls(UrlPrefix, u32), +} + +#[derive(Debug)] +enum ConfigTag<'a> { + AddHeader(&'a str, &'a str), + AddRedirect(UrlPrefix, UrlPrefix, u32), + GlobalLb, + LocalLb, +} + +fn parse_tricot_tags(tag: &str) -> Option { + let splits = tag.splitn(4, ' ').collect::>(); + let parsed_tag = match splits.as_slice() { + ["tricot", raw_prefix, maybe_priority @ ..] => { + // priority is set to 100 when value is invalid or missing + let priority: u32 = maybe_priority + .iter() + .next() + .map_or(Ok(100), |x| x.parse::()) + .unwrap_or(100); + UrlPrefix::new(raw_prefix) + .map(|prefix| ParsedTag::Frontend(MatchTag::Http(prefix, priority))) + } + ["tricot-https", raw_prefix, maybe_priority @ ..] => { + // priority is set to 100 when value is invalid or missing + let priority: u32 = maybe_priority + .iter() + .next() + .map_or(Ok(100), |x| x.parse::()) + .unwrap_or(100); + UrlPrefix::new(raw_prefix) + .map(|prefix| ParsedTag::Frontend(MatchTag::HttpWithTls(prefix, priority))) + } + ["tricot-add-header", header_key, header_value] => Some(ParsedTag::Middleware( + ConfigTag::AddHeader(header_key, header_value), + )), + ["tricot-add-redirect", raw_match, raw_replace, maybe_raw_code @ ..] => { + let (p_match, p_replace) = + match (UrlPrefix::new(raw_match), UrlPrefix::new(raw_replace)) { + (Some(m), Some(r)) => (m, r), + _ => { + debug!( + "tag {} is ignored, one of the url prefix can't be parsed", + tag + ); + return None; + } + }; + + if matches!(p_replace.host, HostDescription::Pattern(_)) { + debug!( + "tag {} ignored as redirect to a glob pattern is not supported", + tag + ); + return None; + } + + let maybe_parsed_code = maybe_raw_code + .iter() + .next() + .map(|c| c.parse::().ok()) + .flatten(); + let http_code = match maybe_parsed_code { + Some(301) => 301, + Some(302) => 302, + _ => { + debug!( + "tag {} has a missing or invalid http code, set it to 302", + tag + ); + 302 + } + }; + + Some(ParsedTag::Middleware(ConfigTag::AddRedirect( + p_match, p_replace, http_code, + ))) + } + ["tricot-global-lb", ..] => Some(ParsedTag::Middleware(ConfigTag::GlobalLb)), + ["tricot-local-lb", ..] => Some(ParsedTag::Middleware(ConfigTag::LocalLb)), + _ => None, + }; + + trace!("tag {} parsed as {:?}", tag, parsed_tag); + parsed_tag } fn parse_consul_service( @@ -208,8 +340,6 @@ fn parse_consul_service( ) -> Vec { trace!("Parsing service: {:#?}", s); - let mut entries = vec![]; - let ip_addr = match s.service.address.parse() { Ok(ip) => ip, _ => match s.node.address.parse() { @@ -225,31 +355,48 @@ fn parse_consul_service( }; let addr = SocketAddr::new(ip_addr, s.service.port); - if s.service.tags.contains(&"tricot-global-lb".into()) { - flags.global_lb = true; - } else if s.service.tags.contains(&"tricot-site-lb".into()) { - flags.site_lb = true; - }; - - let mut add_headers = vec![]; + // tag parsing + let mut collected_middleware = vec![]; + let mut collected_frontends = vec![]; for tag in s.service.tags.iter() { - if let Some(pair) = parse_tricot_add_header_tag(tag) { - add_headers.push(pair); + match parse_tricot_tags(tag) { + Some(ParsedTag::Frontend(x)) => collected_frontends.push(x), + Some(ParsedTag::Middleware(y)) => collected_middleware.push(y), + _ => trace!( + "service {}: tag '{}' could not be parsed", + s.service.service, + tag + ), } } - for tag in s.service.tags.iter() { - if let Some(ent) = parse_tricot_tag( - s.service.service.clone(), - tag, - addr, - &add_headers[..], - flags, - ) { - entries.push(ent); - } + // some legacy processing that would need a refactor later + for mid in collected_middleware.iter() { + match mid { + ConfigTag::AddHeader(_, _) | ConfigTag::AddRedirect(_, _, _) => + /* not handled here */ + { + () + } + ConfigTag::GlobalLb => flags.global_lb = true, + ConfigTag::LocalLb => flags.site_lb = true, + }; } + // build proxy entries + let entries = collected_frontends + .into_iter() + .map(|frt| { + ProxyEntry::new( + s.service.service.clone(), + frt, + addr, + collected_middleware.as_ref(), + flags, + ) + }) + .collect::>(); + trace!("Result of parsing service:"); for ent in entries.iter() { trace!(" {}", ent); @@ -347,8 +494,8 @@ impl ProxyConfigMetrics { let mut patterns = HashMap::new(); for ent in rx.borrow().entries.iter() { let attrs = ( - ent.host.to_string(), - ent.path_prefix.clone().unwrap_or_default(), + ent.url_prefix.host.to_string(), + ent.url_prefix.path_prefix.clone().unwrap_or_default(), ent.service_name.clone(), ); *patterns.entry(attrs).or_default() += 1; -- 2.43.4 From f11592926b9548c0797c02bd5aa4550a1f251c98 Mon Sep 17 00:00:00 2001 From: Quentin Dufour Date: Wed, 29 Nov 2023 13:06:32 +0100 Subject: [PATCH 2/4] fix tests --- src/proxy_config.rs | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/proxy_config.rs b/src/proxy_config.rs index eebff72..a481058 100644 --- a/src/proxy_config.rs +++ b/src/proxy_config.rs @@ -144,16 +144,10 @@ impl ProxyEntry { let mut redirects = vec![]; for mid in middleware.into_iter() { match mid { - ConfigTag::AddHeader(k, v) => add_headers.push((k.to_string(), v.to_string())), + ConfigTag::AddHeader(k, v) => add_headers.push((k.to_string(), v.clone())), ConfigTag::AddRedirect(m, r, c) => redirects.push(((*m).clone(), (*r).clone(), *c)), - ConfigTag::LocalLb => - /* handled in parent fx */ - { - () - } - ConfigTag::GlobalLb => - /* handled in parent fx */ - { + ConfigTag::LocalLb | ConfigTag::GlobalLb => { + /* handled in parent fx */ () } }; @@ -251,7 +245,7 @@ enum MatchTag { #[derive(Debug)] enum ConfigTag<'a> { - AddHeader(&'a str, &'a str), + AddHeader(&'a str, String), AddRedirect(UrlPrefix, UrlPrefix, u32), GlobalLb, LocalLb, @@ -280,8 +274,8 @@ fn parse_tricot_tags(tag: &str) -> Option { UrlPrefix::new(raw_prefix) .map(|prefix| ParsedTag::Frontend(MatchTag::HttpWithTls(prefix, priority))) } - ["tricot-add-header", header_key, header_value] => Some(ParsedTag::Middleware( - ConfigTag::AddHeader(header_key, header_value), + ["tricot-add-header", header_key, header_values @ ..] => Some(ParsedTag::Middleware( + ConfigTag::AddHeader(header_key, header_values.join(" ")), )), ["tricot-add-redirect", raw_match, raw_replace, maybe_raw_code @ ..] => { let (p_match, p_replace) = @@ -525,8 +519,8 @@ mod tests { #[test] fn test_parse_tricot_add_header_tag() { - match parse_tricot_add_header_tag("tricot-add-header Content-Security-Policy default-src 'none'; img-src 'self'; script-src 'self'; style-src 'self'") { - Some((name, value)) => { + match parse_tricot_tags("tricot-add-header Content-Security-Policy default-src 'none'; img-src 'self'; script-src 'self'; style-src 'self'") { + Some(ParsedTag::Middleware(ConfigTag::AddHeader(name, value))) => { assert_eq!(name, "Content-Security-Policy"); assert_eq!(value, "default-src 'none'; img-src 'self'; script-src 'self'; style-src 'self'"); } -- 2.43.4 From 2b3f934247e0c35f53822d600ec34483b4092e5c Mon Sep 17 00:00:00 2001 From: Quentin Dufour Date: Wed, 29 Nov 2023 15:50:25 +0100 Subject: [PATCH 3/4] implement redirection in https.rs --- src/https.rs | 66 ++++++++++++++++++++++++++++++++++++++++----- src/proxy_config.rs | 10 ++++--- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/https.rs b/src/https.rs index 5f8a17e..9d92470 100644 --- a/src/https.rs +++ b/src/https.rs @@ -24,7 +24,7 @@ use tokio_util::io::{ReaderStream, StreamReader}; use opentelemetry::{metrics, KeyValue}; use crate::cert_store::{CertStore, StoreResolver}; -use crate::proxy_config::{ProxyConfig, ProxyEntry}; +use crate::proxy_config::{HostDescription, ProxyConfig, ProxyEntry}; use crate::reverse_proxy; const MAX_CONNECTION_LIFETIME: Duration = Duration::from_secs(24 * 3600); @@ -272,15 +272,22 @@ async fn select_target_and_proxy( ); proxy_to.calls_in_progress.fetch_add(1, Ordering::SeqCst); + // Forward to backend debug!("{}{} -> {}", host, path, proxy_to); trace!("Request: {:?}", req); - let response = match do_proxy(https_config, remote_addr, req, proxy_to).await { - Ok(resp) => resp, - Err(e) => Response::builder() - .status(StatusCode::BAD_GATEWAY) - .body(Body::from(format!("Proxy error: {}", e))) - .unwrap(), + let response = if let Some(http_res) = try_redirect(host, path, proxy_to) { + // redirection middleware + http_res + } else { + // proxying to backend + match do_proxy(https_config, remote_addr, req, proxy_to).await { + Ok(resp) => resp, + Err(e) => Response::builder() + .status(StatusCode::BAD_GATEWAY) + .body(Body::from(format!("Proxy error: {}", e))) + .unwrap(), + } }; proxy_to.calls_in_progress.fetch_sub(1, Ordering::SeqCst); @@ -302,6 +309,51 @@ async fn select_target_and_proxy( } } +fn try_redirect(req_host: &str, req_path: &str, proxy_to: &ProxyEntry) -> Option> { + let maybe_redirect = proxy_to.redirects.iter().find(|(src, _, _)| { + let mut matched: bool = src.host.matches(req_host); + + if let Some(path) = &src.path_prefix { + matched &= req_path.starts_with(path); + } + + matched + }); + + let (src_prefix, dst_prefix, code) = match maybe_redirect { + None => return None, + Some(redirect) => redirect, + }; + + let new_host = match &dst_prefix.host { + HostDescription::Hostname(h) => h, + _ => unreachable!(), // checked when ProxyEntry is created + }; + + let new_prefix = dst_prefix.path_prefix.as_deref().unwrap_or(""); + let original_prefix = src_prefix.path_prefix.as_deref().unwrap_or(""); + let suffix = &req_path[original_prefix.len()..]; + + let uri = format!("https://{}{}{}", new_host, new_prefix, suffix); + + let status = match StatusCode::from_u16(*code) { + Err(e) => { + warn!( + "Couldn't redirect {}{} to {} as code {} in invalid: {}", + req_host, req_path, uri, code, e + ); + return None; + } + Ok(sc) => sc, + }; + + Response::builder() + .header("Location", uri.clone()) + .status(status) + .body(Body::from(uri)) + .ok() +} + async fn do_proxy( https_config: &HttpsConfig, remote_addr: SocketAddr, diff --git a/src/proxy_config.rs b/src/proxy_config.rs index a481058..8381de2 100644 --- a/src/proxy_config.rs +++ b/src/proxy_config.rs @@ -106,7 +106,7 @@ pub struct ProxyEntry { /// Try to match all these redirection before forwarding to the backend /// when matching this rule - pub redirects: Vec<(UrlPrefix, UrlPrefix, u32)>, + pub redirects: Vec<(UrlPrefix, UrlPrefix, u16)>, /// Number of calls in progress, used to deprioritize slow back-ends pub calls_in_progress: atomic::AtomicI64, @@ -246,7 +246,7 @@ enum MatchTag { #[derive(Debug)] enum ConfigTag<'a> { AddHeader(&'a str, String), - AddRedirect(UrlPrefix, UrlPrefix, u32), + AddRedirect(UrlPrefix, UrlPrefix, u16), GlobalLb, LocalLb, } @@ -301,14 +301,16 @@ fn parse_tricot_tags(tag: &str) -> Option { let maybe_parsed_code = maybe_raw_code .iter() .next() - .map(|c| c.parse::().ok()) + .map(|c| c.parse::().ok()) .flatten(); let http_code = match maybe_parsed_code { Some(301) => 301, Some(302) => 302, + Some(303) => 303, + Some(307) => 307, _ => { debug!( - "tag {} has a missing or invalid http code, set it to 302", + "tag {} has a missing or invalid http code, setting it to 302", tag ); 302 -- 2.43.4 From b76b6dcbcc47ebc61848389a6b0d5d4e8d8cde48 Mon Sep 17 00:00:00 2001 From: Quentin Dufour Date: Wed, 29 Nov 2023 16:17:42 +0100 Subject: [PATCH 4/4] add tricot-add-redirect in readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ae46d8e..63ef8dd 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Backends are configured by adding tags of the following form to the services in - `tricot myapp.example.com/path/to_subresource 10`: combining the previous two examples - `tricot-https myapp.example.com`: same, but indicates that the backend service handling the request expects an HTTPS request and not an HTTP request. In this case, Tricot will do everything in its power to NOT verify the backend's TLS certificate (ignore self-signed certificate, ignore TLS hostname, etc). - `tricot-add-header Access-Control-Allow-Origin *`: add the `Access-Control-Allow-Origin: *` header to all of the HTTP responses when they are proxied back to the client +- `tricot-add-redirect old.example.com/maybe_subpath new.example.com/new/subpath 301`: redirects paths that match the first pattern to the second pattern with the given HTTP status code. More info in [PR#10](https://git.deuxfleurs.fr/Deuxfleurs/tricot/pulls/10). - `tricot-global-lb`: load-balance incoming requests to all matching backends - `tricot-site-lb`: load-balance incoming requests to all matching backends that are in the same site (geographical location); when site information about nodes is not available, this is equivalent to `tricot-global-lb` -- 2.43.4