Merge pull request 'New directive `tricot-add-redirect <match-prefix> <redirect-prefix> [301|302|303|307]`' (#10) from redirect into main
continuous-integration/drone/push Build is passing Details

Reviewed-on: #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-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`

View File

@ -78,7 +78,7 @@ impl CertStore {
let proxy_config: Arc<ProxyConfig> = 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);
}

View File

@ -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);
@ -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),
@ -270,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);
@ -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(
https_config: &HttpsConfig,
remote_addr: SocketAddr,

View File

@ -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);
}

View File

@ -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<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)
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, u16)>,
/// 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,52 @@ 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.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)]
pub struct ProxyEntryFlags {
/// Is the target healthy?
@ -115,8 +200,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 +229,105 @@ pub struct ProxyConfig {
pub entries: Vec<ProxyEntry>,
}
fn parse_tricot_tag(
service_name: String,
tag: &str,
target_addr: SocketAddr,
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('/') {
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::<Vec<_>>();
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, String),
AddRedirect(UrlPrefix, UrlPrefix, u16),
GlobalLb,
LocalLb,
}
fn parse_tricot_tags(tag: &str) -> Option<ParsedTag> {
let splits = tag.splitn(4, ' ').collect::<Vec<_>>();
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::<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;
}
};
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::<u16>().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, 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(
@ -208,8 +336,6 @@ fn parse_consul_service(
) -> Vec<ProxyEntry> {
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 +351,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::<Vec<_>>();
trace!("Result of parsing service:");
for ent in entries.iter() {
trace!(" {}", ent);
@ -347,8 +490,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;
@ -378,8 +521,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'");
}