use crate::internal::{Ingress, IngressType};
use k8s_crds_gateway::{
BackendTlsPolicy, BackendTlsPolicySpec, BackendTlsPolicyTargetRefs, BackendTlsPolicyValidation,
HttpRoute, HttpRouteRules, HttpRouteRulesBackendRefs, HttpRouteRulesFilters,
HttpRouteRulesFiltersExtensionRef, HttpRouteRulesFiltersRequestRedirectScheme,
HttpRouteRulesFiltersType, HttpRouteRulesFiltersUrlRewrite,
HttpRouteRulesFiltersUrlRewritePath, HttpRouteRulesFiltersUrlRewritePathType,
HttpRouteRulesMatches, HttpRouteRulesMatchesPath, HttpRouteRulesMatchesPathType, HttpRouteSpec,
};
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use sha2::{Digest, Sha256};
use slugify::slugify;
use std::fmt::Write;
fn middleware_filter(mw_name: String) -> HttpRouteRulesFilters {
HttpRouteRulesFilters {
cors: None,
r#type: HttpRouteRulesFiltersType::ExtensionRef,
extension_ref: Some(HttpRouteRulesFiltersExtensionRef {
group: "traefik.io".to_string(),
kind: "Middleware".to_string(),
name: mw_name,
}),
request_header_modifier: None,
request_mirror: None,
request_redirect: None,
response_header_modifier: None,
url_rewrite: None,
}
}
const fn strip_prefix_filter(prefix: String) -> HttpRouteRulesFilters {
HttpRouteRulesFilters {
cors: None,
extension_ref: None,
request_header_modifier: None,
request_mirror: None,
request_redirect: None,
response_header_modifier: None,
r#type: HttpRouteRulesFiltersType::UrlRewrite,
url_rewrite: Some(HttpRouteRulesFiltersUrlRewrite {
hostname: None,
path: Some(HttpRouteRulesFiltersUrlRewritePath {
replace_full_path: None,
replace_prefix_match: Some(prefix),
r#type: HttpRouteRulesFiltersUrlRewritePathType::ReplacePrefixMatch,
}),
}),
}
}
fn join_path_prefixes(prefix_a: &str, prefix_b: &str) -> String {
let mut joined = String::new();
if !prefix_a.starts_with('/') {
joined.push('/');
}
joined.push_str(prefix_a);
if !prefix_a.ends_with('/') && !prefix_b.starts_with('/') {
joined.push('/');
}
joined.push_str(prefix_b);
joined
}
trait IngressHash {
fn get_hash(&self) -> String;
}
fn hash_field(hasher: &mut Sha256, name: &str, value: &[u8]) {
for part in [name.as_bytes(), value] {
hasher.update((part.len() as u64).to_be_bytes());
hasher.update(part);
}
}
impl IngressHash for Ingress {
fn get_hash(&self) -> String {
#[allow(deprecated)]
let Ingress {
target_service,
target_ns,
target_app,
path_prefix,
target_port,
r#type,
enable_compression,
strip_prefix,
auth_exclude,
component,
use_https,
use_https_with_hostname,
tls_insecure,
} = self;
let mut hasher = Sha256::new();
let strings = [
("target_service", target_service),
("target_ns", target_ns),
("target_app", target_app),
("path_prefix", path_prefix),
("component", component),
("use_https_with_hostname", use_https_with_hostname),
];
for (name, value) in strings {
if let Some(value) = value {
hash_field(&mut hasher, name, value.as_bytes());
}
}
if let Some(port) = target_port {
hash_field(&mut hasher, "target_port", &port.to_be_bytes());
}
let type_name = match r#type {
IngressType::HttpFallback => Some("http_fallback"),
IngressType::Https => None,
IngressType::TlsTcp => Some("tls_tcp"),
};
if let Some(type_name) = type_name {
hash_field(&mut hasher, "type", type_name.as_bytes());
}
let flags = [
("enable_compression", enable_compression),
("strip_prefix", strip_prefix),
("auth_exclude", auth_exclude),
("use_https", use_https),
("tls_insecure", tls_insecure),
];
for (name, value) in flags {
if *value {
hash_field(&mut hasher, name, &[1]);
}
}
hasher.finalize()[..8]
.iter()
.fold(String::new(), |mut out, b| {
let _ = write!(out, "{b:02x}");
out
})
}
}
#[must_use]
pub fn generate_tls_policies(route_name: &str, sources: &[&Ingress]) -> Vec<BackendTlsPolicy> {
sources
.iter()
.filter(|s| s.use_https || s.use_https_with_hostname.is_some())
.map(|s| BackendTlsPolicy {
metadata: ObjectMeta {
name: Some(route_name.to_string()),
..Default::default()
},
spec: BackendTlsPolicySpec {
options: None,
target_refs: vec![BackendTlsPolicyTargetRefs {
group: "gateway.networking.k8s.io".to_string(),
kind: "HTTPRoute".to_string(),
name: route_name.to_string(),
section_name: Some(s.get_hash()),
}],
validation: BackendTlsPolicyValidation {
ca_certificate_refs: None,
hostname: if let Some(ref hostname) = s.use_https_with_hostname {
hostname.clone()
} else if let Some(ref target_service) = s.target_service {
target_service.clone()
} else {
"unknownSvc.local".to_string()
},
subject_alt_names: None,
well_known_ca_certificates: None,
},
},
status: None,
})
.collect()
}
#[must_use]
pub fn get_app_route(
domain: Option<&String>,
sources: &[Ingress],
is_tls: bool,
user: &str,
protect: bool,
custom_prefix: Option<&String>,
component_id: Option<&String>,
) -> (HttpRoute, Vec<BackendTlsPolicy>) {
let sources: Vec<&Ingress> = sources
.iter()
.filter(|source| source.component.as_ref() == component_id)
.collect();
let use_http_fallback_routes = !is_tls
&& sources
.iter()
.any(|source| source.r#type == IngressType::HttpFallback);
let mapper = |source: &Ingress| {
let mut middlewares = Vec::new();
if source.enable_compression {
middlewares.push(middleware_filter("compress-v2".to_string()));
}
if protect && !source.auth_exclude {
middlewares.push(middleware_filter(format!("auth-{user}")));
}
let prefix;
if source.strip_prefix
&& let Some(ref path_prefix) = source.path_prefix
&& path_prefix != "/"
{
if let Some(custom_prefix) = custom_prefix {
prefix = Some(join_path_prefixes(custom_prefix, path_prefix));
} else {
prefix = Some(path_prefix.clone());
}
} else {
prefix = custom_prefix.cloned();
}
if let Some(ref prefix) = prefix {
middlewares.push(strip_prefix_filter(prefix.clone()));
}
HttpRouteRules {
backend_refs: Some(
if let (Some(svc), Some(port)) =
(source.target_service.as_ref(), source.target_port)
{
vec![HttpRouteRulesBackendRefs {
filters: None,
group: None,
kind: Some("Service".to_string()),
name: svc.clone(),
port: Some(i32::from(port)),
namespace: source.target_ns.clone().or_else(|| {
source
.target_app
.as_ref()
.map(|app| format!("{user}-{app}"))
}),
weight: None,
}]
} else {
vec![]
},
),
filters: if middlewares.is_empty() {
None
} else {
Some(middlewares)
},
matches: if let Some(prefix) = prefix {
Some(vec![HttpRouteRulesMatches {
headers: None,
method: None,
path: Some(HttpRouteRulesMatchesPath {
r#type: Some(HttpRouteRulesMatchesPathType::PathPrefix),
value: Some(prefix),
}),
query_params: None,
}])
} else {
None
},
name: Some(source.get_hash()),
timeouts: None,
}
};
let route_name = if let Some(domain) = domain.as_ref() {
slugify!(domain)
} else {
"fallback-ingress".to_string()
};
let sources: Vec<_> = sources
.into_iter()
.filter(|source| {
if use_http_fallback_routes {
source.r#type == IngressType::HttpFallback
} else {
source.r#type == IngressType::Https
}
})
.collect();
let policies = generate_tls_policies(&route_name, &sources);
(
HttpRoute {
metadata: ObjectMeta {
name: Some(route_name),
..Default::default()
},
spec: HttpRouteSpec {
hostnames: domain.map(|d| vec![d.clone()]),
parent_refs: Some(vec![k8s_crds_gateway::HttpRouteParentRefs {
group: Some("gateway.networking.k8s.io".to_string()),
kind: Some("Gateway".to_string()),
name: "n5i".to_string(),
namespace: Some(n5i::distro::DISTRO_ID.to_string()),
port: Some(
std::env::var("GATEWAY_TLS_LISTENER_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(443),
),
section_name: None,
}]),
rules: Some(sources.into_iter().map(mapper).collect()),
},
status: None,
},
policies,
)
}
#[must_use]
pub fn get_app_routes(
domain: &String,
sources: &[Ingress],
user: &str,
protect: bool,
custom_prefix: Option<&String>,
component_id: Option<&String>,
) -> ([HttpRoute; 2], Vec<BackendTlsPolicy>) {
let sources: Vec<Ingress> = sources
.iter()
.filter_map(|source| {
if source.component.as_ref() == component_id {
Some(source.clone())
} else {
None
}
})
.collect();
let has_fallback = sources
.iter()
.any(|source| source.r#type == IngressType::HttpFallback);
let (main_ingress, mut policies) = get_app_route(
Some(domain),
&sources,
true,
user,
protect,
custom_prefix,
component_id,
);
let http_route = if has_fallback {
let (mut http_route, mut additional_policies) = get_app_route(
Some(domain),
&sources,
false,
user,
protect,
custom_prefix,
component_id,
);
http_route.metadata.name = Some(format!("{}-http", slugify!(&domain)));
policies.append(&mut additional_policies);
http_route
} else {
HttpRoute {
metadata: ObjectMeta {
name: Some(format!("{}-http", slugify!(&domain))),
..Default::default()
},
spec: HttpRouteSpec {
hostnames: Some(vec![domain.clone()]),
parent_refs: Some(vec![k8s_crds_gateway::HttpRouteParentRefs {
group: Some("gateway.networking.k8s.io".to_string()),
kind: Some("Gateway".to_string()),
name: "n5i".to_string(),
namespace: Some(n5i::distro::DISTRO_ID.to_string()),
port: Some(80),
section_name: None,
}]),
rules: Some(vec![HttpRouteRules {
name: Some("http-redirect".to_string()),
matches: None,
filters: Some(vec![HttpRouteRulesFilters {
r#type: HttpRouteRulesFiltersType::RequestRedirect,
request_redirect: Some(
k8s_crds_gateway::HttpRouteRulesFiltersRequestRedirect {
hostname: None,
scheme: Some(HttpRouteRulesFiltersRequestRedirectScheme::Https),
port: None,
status_code: None,
path: None,
},
),
cors: None,
extension_ref: None,
request_header_modifier: None,
request_mirror: None,
response_header_modifier: None,
url_rewrite: None,
}]),
backend_refs: None,
timeouts: None,
}]),
},
status: None,
}
};
([main_ingress, http_route], policies)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Ingress {
Ingress {
target_service: Some("web".to_string()),
target_port: Some(8080),
path_prefix: Some("/api".to_string()),
strip_prefix: true,
..Default::default()
}
}
#[test]
fn hash_is_pinned() {
assert_eq!(Ingress::default().get_hash(), "e3b0c44298fc1c14");
assert_eq!(sample().get_hash(), "3f34f64cc5d4ccea");
}
#[test]
fn hash_distinguishes_fields() {
let mut other = sample();
other.target_ns = other.target_service.take();
assert_ne!(sample().get_hash(), other.get_hash());
let mut other = sample();
other.r#type = IngressType::TlsTcp;
assert_ne!(sample().get_hash(), other.get_hash());
}
}