Merge pull request 'New directive tricot-add-redirect <match-prefix> <redirect-prefix> [301|302|303|307]' (#10) from redirect into main

Reviewed-on: Deuxfleurs/tricot#10
This commit is contained in:
Quentin 2023-11-29 16:09:56 +00:00
commit b04c2bfb0a
5 changed files with 296 additions and 98 deletions

View file

@ -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 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-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-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-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` - `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`

View file

@ -78,7 +78,7 @@ impl CertStore {
let proxy_config: Arc<ProxyConfig> = rx_proxy_config.borrow().clone(); let proxy_config: Arc<ProxyConfig> = rx_proxy_config.borrow().clone();
for ent in proxy_config.entries.iter() { 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(':') { if let Some((host, _port)) = domain.split_once(':') {
domains.insert(host.to_string()); domains.insert(host.to_string());
} else { } else {
@ -121,7 +121,7 @@ impl CertStore {
.borrow() .borrow()
.entries .entries
.iter() .iter()
.any(|ent| ent.host.matches(domain)) .any(|ent| ent.url_prefix.host.matches(domain))
{ {
bail!("Domain {} should not have a TLS certificate.", domain); bail!("Domain {} should not have a TLS certificate.", domain);
} }

View file

@ -24,7 +24,7 @@ use tokio_util::io::{ReaderStream, StreamReader};
use opentelemetry::{metrics, KeyValue}; use opentelemetry::{metrics, KeyValue};
use crate::cert_store::{CertStore, StoreResolver}; use crate::cert_store::{CertStore, StoreResolver};
use crate::proxy_config::{ProxyConfig, ProxyEntry}; use crate::proxy_config::{HostDescription, ProxyConfig, ProxyEntry};
use crate::reverse_proxy; use crate::reverse_proxy;
const MAX_CONNECTION_LIFETIME: Duration = Duration::from_secs(24 * 3600); const MAX_CONNECTION_LIFETIME: Duration = Duration::from_secs(24 * 3600);
@ -234,8 +234,9 @@ async fn select_target_and_proxy(
.iter() .iter()
.filter(|ent| { .filter(|ent| {
ent.flags.healthy ent.flags.healthy
&& ent.host.matches(host) && ent.url_prefix.host.matches(host)
&& ent && ent
.url_prefix
.path_prefix .path_prefix
.as_ref() .as_ref()
.map(|prefix| path.starts_with(prefix)) .map(|prefix| path.starts_with(prefix))
@ -244,7 +245,8 @@ async fn select_target_and_proxy(
.max_by_key(|ent| { .max_by_key(|ent| {
( (
ent.priority, ent.priority,
ent.path_prefix ent.url_prefix
.path_prefix
.as_ref() .as_ref()
.map(|x| x.len() as i32) .map(|x| x.len() as i32)
.unwrap_or(0), .unwrap_or(0),
@ -270,15 +272,22 @@ async fn select_target_and_proxy(
); );
proxy_to.calls_in_progress.fetch_add(1, Ordering::SeqCst); proxy_to.calls_in_progress.fetch_add(1, Ordering::SeqCst);
// Forward to backend
debug!("{}{} -> {}", host, path, proxy_to); debug!("{}{} -> {}", host, path, proxy_to);
trace!("Request: {:?}", req); trace!("Request: {:?}", req);
let response = match do_proxy(https_config, remote_addr, req, proxy_to).await { 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, Ok(resp) => resp,
Err(e) => Response::builder() Err(e) => Response::builder()
.status(StatusCode::BAD_GATEWAY) .status(StatusCode::BAD_GATEWAY)
.body(Body::from(format!("Proxy error: {}", e))) .body(Body::from(format!("Proxy error: {}", e)))
.unwrap(), .unwrap(),
}
}; };
proxy_to.calls_in_progress.fetch_sub(1, Ordering::SeqCst); proxy_to.calls_in_progress.fetch_sub(1, Ordering::SeqCst);
@ -300,6 +309,51 @@ async fn select_target_and_proxy(
} }
} }
fn try_redirect(req_host: &str, req_path: &str, proxy_to: &ProxyEntry) -> Option<Response<Body>> {
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( async fn do_proxy(
https_config: &HttpsConfig, https_config: &HttpsConfig,
remote_addr: SocketAddr, remote_addr: SocketAddr,

View file

@ -239,7 +239,7 @@ async fn dump_config_on_change(
let mut cfg_map = BTreeMap::<_, Vec<_>>::new(); let mut cfg_map = BTreeMap::<_, Vec<_>>::new();
for ent in cfg.entries.iter() { for ent in cfg.entries.iter() {
cfg_map cfg_map
.entry((&ent.host, &ent.path_prefix)) .entry((&ent.url_prefix.host, &ent.url_prefix.path_prefix))
.or_default() .or_default()
.push(ent); .push(ent);
} }

View file

@ -13,7 +13,7 @@ use crate::consul;
// ---- Extract proxy config from Consul catalog ---- // ---- Extract proxy config from Consul catalog ----
#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] #[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub enum HostDescription { pub enum HostDescription {
Hostname(String), Hostname(String),
Pattern(glob::Pattern), Pattern(glob::Pattern),
@ -45,12 +45,48 @@ impl std::fmt::Display for HostDescription {
} }
} }
#[derive(Debug)] #[derive(Debug, Clone)]
pub struct ProxyEntry { pub struct UrlPrefix {
/// Publicly exposed TLS hostnames for matching this rule /// Publicly exposed TLS hostnames for matching this rule
pub host: HostDescription, pub host: HostDescription,
/// Path prefix for matching this rule /// Path prefix for matching this rule
pub path_prefix: Option<String>, pub path_prefix: Option<String>,
}
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<Self> {
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) /// Priority with which this rule is considered (highest first)
pub priority: u32, pub priority: u32,
@ -68,6 +104,10 @@ pub struct ProxyEntry {
/// when matching this rule /// when matching this rule
pub add_headers: Vec<(String, String)>, 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, u16)>,
/// Number of calls in progress, used to deprioritize slow back-ends /// Number of calls in progress, used to deprioritize slow back-ends
pub calls_in_progress: atomic::AtomicI64, pub calls_in_progress: atomic::AtomicI64,
/// Time of last call, used for round-robin selection /// Time of last call, used for round-robin selection
@ -76,8 +116,7 @@ pub struct ProxyEntry {
impl PartialEq for ProxyEntry { impl PartialEq for ProxyEntry {
fn eq(&self, other: &Self) -> bool { fn eq(&self, other: &Self) -> bool {
self.host == other.host self.url_prefix == other.url_prefix
&& self.path_prefix == other.path_prefix
&& self.priority == other.priority && self.priority == other.priority
&& self.service_name == other.service_name && self.service_name == other.service_name
&& self.target_addr == other.target_addr && self.target_addr == other.target_addr
@ -88,6 +127,52 @@ impl PartialEq for ProxyEntry {
} }
impl Eq 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.clone())),
ConfigTag::AddRedirect(m, r, c) => redirects.push(((*m).clone(), (*r).clone(), *c)),
ConfigTag::LocalLb | 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)] #[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub struct ProxyEntryFlags { pub struct ProxyEntryFlags {
/// Is the target healthy? /// Is the target healthy?
@ -115,8 +200,8 @@ impl std::fmt::Display for ProxyEntry {
write!( write!(
f, f,
"{}{} {}", "{}{} {}",
self.host, self.url_prefix.host,
self.path_prefix.as_deref().unwrap_or_default(), self.url_prefix.path_prefix.as_deref().unwrap_or_default(),
self.priority self.priority
)?; )?;
if !self.flags.healthy { if !self.flags.healthy {
@ -144,62 +229,105 @@ pub struct ProxyConfig {
pub entries: Vec<ProxyEntry>, pub entries: Vec<ProxyEntry>,
} }
fn parse_tricot_tag( #[derive(Debug)]
service_name: String, enum ParsedTag<'a> {
tag: &str, Frontend(MatchTag),
target_addr: SocketAddr, Middleware(ConfigTag<'a>),
add_headers: &[(String, String)],
flags: ProxyEntryFlags,
) -> Option<ProxyEntry> {
let splits = tag.split(' ').collect::<Vec<_>>();
if (splits.len() != 2 && splits.len() != 3)
|| (splits[0] != "tricot" && splits[0] != "tricot-https")
{
return None;
} }
let (host, path_prefix) = match splits[1].find('/') { #[derive(Debug)]
Some(i) => { enum MatchTag {
let (host, pp) = splits[1].split_at(i); /// HTTP backend (plain text)
(host, Some(pp.to_string())) Http(UrlPrefix, u32),
/// HTTPS backend (TLS encrypted)
HttpWithTls(UrlPrefix, u32),
} }
None => (splits[1], None),
};
let priority = match splits.len() { #[derive(Debug)]
3 => splits[2].parse().ok()?, enum ConfigTag<'a> {
_ => 100, AddHeader(&'a str, String),
}; AddRedirect(UrlPrefix, UrlPrefix, u16),
GlobalLb,
LocalLb,
}
let host = match HostDescription::new(host) { fn parse_tricot_tags(tag: &str) -> Option<ParsedTag> {
Ok(h) => h, let splits = tag.splitn(4, ' ').collect::<Vec<_>>();
Err(e) => { let parsed_tag = match splits.as_slice() {
warn!("Invalid hostname pattern {}: {}", host, e); ["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::<u32>())
.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::<u32>())
.unwrap_or(100);
UrlPrefix::new(raw_prefix)
.map(|prefix| ParsedTag::Frontend(MatchTag::HttpWithTls(prefix, priority)))
}
["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) =
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; return None;
} }
}; };
Some(ProxyEntry { if matches!(p_replace.host, HostDescription::Pattern(_)) {
service_name, debug!(
target_addr, "tag {} ignored as redirect to a glob pattern is not supported",
https_target: (splits[0] == "tricot-https"), tag
host, );
flags, return None;
path_prefix,
priority,
add_headers: add_headers.to_vec(),
last_call: atomic::AtomicI64::from(0),
calls_in_progress: atomic::AtomicI64::from(0),
})
} }
fn parse_tricot_add_header_tag(tag: &str) -> Option<(String, String)> { let maybe_parsed_code = maybe_raw_code
let splits = tag.splitn(3, ' ').collect::<Vec<_>>(); .iter()
if splits.len() == 3 && splits[0] == "tricot-add-header" { .next()
Some((splits[1].to_string(), splits[2].to_string())) .map(|c| c.parse::<u16>().ok())
} else { .flatten();
None 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, setting 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( fn parse_consul_service(
@ -208,8 +336,6 @@ fn parse_consul_service(
) -> Vec<ProxyEntry> { ) -> Vec<ProxyEntry> {
trace!("Parsing service: {:#?}", s); trace!("Parsing service: {:#?}", s);
let mut entries = vec![];
let ip_addr = match s.service.address.parse() { let ip_addr = match s.service.address.parse() {
Ok(ip) => ip, Ok(ip) => ip,
_ => match s.node.address.parse() { _ => match s.node.address.parse() {
@ -225,30 +351,47 @@ fn parse_consul_service(
}; };
let addr = SocketAddr::new(ip_addr, s.service.port); let addr = SocketAddr::new(ip_addr, s.service.port);
if s.service.tags.contains(&"tricot-global-lb".into()) { // tag parsing
flags.global_lb = true; let mut collected_middleware = vec![];
} else if s.service.tags.contains(&"tricot-site-lb".into()) { let mut collected_frontends = vec![];
flags.site_lb = true; for tag in s.service.tags.iter() {
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
),
}
}
// 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,
}; };
let mut add_headers = vec![];
for tag in s.service.tags.iter() {
if let Some(pair) = parse_tricot_add_header_tag(tag) {
add_headers.push(pair);
}
} }
for tag in s.service.tags.iter() { // build proxy entries
if let Some(ent) = parse_tricot_tag( let entries = collected_frontends
.into_iter()
.map(|frt| {
ProxyEntry::new(
s.service.service.clone(), s.service.service.clone(),
tag, frt,
addr, addr,
&add_headers[..], collected_middleware.as_ref(),
flags, flags,
) { )
entries.push(ent); })
} .collect::<Vec<_>>();
}
trace!("Result of parsing service:"); trace!("Result of parsing service:");
for ent in entries.iter() { for ent in entries.iter() {
@ -347,8 +490,8 @@ impl ProxyConfigMetrics {
let mut patterns = HashMap::new(); let mut patterns = HashMap::new();
for ent in rx.borrow().entries.iter() { for ent in rx.borrow().entries.iter() {
let attrs = ( let attrs = (
ent.host.to_string(), ent.url_prefix.host.to_string(),
ent.path_prefix.clone().unwrap_or_default(), ent.url_prefix.path_prefix.clone().unwrap_or_default(),
ent.service_name.clone(), ent.service_name.clone(),
); );
*patterns.entry(attrs).or_default() += 1; *patterns.entry(attrs).or_default() += 1;
@ -378,8 +521,8 @@ mod tests {
#[test] #[test]
fn test_parse_tricot_add_header_tag() { 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'") { match parse_tricot_tags("tricot-add-header Content-Security-Policy default-src 'none'; img-src 'self'; script-src 'self'; style-src 'self'") {
Some((name, value)) => { Some(ParsedTag::Middleware(ConfigTag::AddHeader(name, value))) => {
assert_eq!(name, "Content-Security-Policy"); assert_eq!(name, "Content-Security-Policy");
assert_eq!(value, "default-src 'none'; img-src 'self'; script-src 'self'; style-src 'self'"); assert_eq!(value, "default-src 'none'; img-src 'self'; script-src 'self'; style-src 'self'");
} }