policy: Add HTTPRoute indexing (#8795)

linkerd2-proxy-api v0.6.0 adds support for inbound proxies to discover
route configurations based on the Gateway API HTTPRoute types. This
change updates the policy controller to index
`gateway.networking.k8s.io/v1beta` `HTTPRoute` types to discover these
policies from the Kubernetes API.

`HTTPRoute` resources may target `Server` resources (as a `parentRef`)
to attach policies to an inbound proxy. When no routes are configured,
a default route is synthesized to allow traffic; but when at least one
route attaches to a server, only requests that match a route are
permitted (other requests are failed with a 404).

Only the *core* subset of the `HTTPRoute` filters are supported:
`RequestRedirect` and `RequestHeaderModifier`. Backends may *not* be
configured on these routes (since they may only apply to inbound/server-
side proxies). No `status` updates are currently performed on these
`HTTPRoute` resources.

This change does not yet allow `AuthorizationPolicy` resources to target
`HTTPRoute` resources. This will be added in a follow-up change.

Signed-off-by: Alex Leong <alex@buoyant.io>
Co-authored-by: Oliver Gould <ver@buoyant.io>
This commit is contained in:
Alex Leong 2022-07-14 09:04:48 -07:00 committed by GitHub
parent 698c0593c6
commit f04edae759
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 1219 additions and 49 deletions

View File

@ -13,6 +13,15 @@ dependencies = [
"version_check",
]
[[package]]
name = "aho-corasick"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f"
dependencies = [
"memchr",
]
[[package]]
name = "ansi_term"
version = "0.12.1"
@ -973,7 +982,9 @@ dependencies = [
"anyhow",
"async-trait",
"futures",
"http",
"ipnet",
"regex",
]
[[package]]
@ -1016,11 +1027,13 @@ dependencies = [
"ahash",
"anyhow",
"futures",
"k8s-gateway-api",
"kubert",
"linkerd-policy-controller-core",
"linkerd-policy-controller-k8s-api",
"maplit",
"parking_lot",
"thiserror",
"tokio",
"tokio-stream",
"tokio-test",
@ -1452,6 +1465,8 @@ version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]

View File

@ -207,6 +207,14 @@ rules:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- list
- get
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View File

@ -210,6 +210,14 @@ rules:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- list
- get
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View File

@ -210,6 +210,14 @@ rules:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- list
- get
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View File

@ -210,6 +210,14 @@ rules:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- list
- get
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View File

@ -210,6 +210,14 @@ rules:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- list
- get
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View File

@ -210,6 +210,14 @@ rules:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- list
- get
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View File

@ -210,6 +210,14 @@ rules:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- list
- get
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View File

@ -210,6 +210,14 @@ rules:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- list
- get
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View File

@ -210,6 +210,14 @@ rules:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- list
- get
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View File

@ -210,6 +210,14 @@ rules:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- list
- get
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View File

@ -202,6 +202,14 @@ rules:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- list
- get
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View File

@ -202,6 +202,14 @@ rules:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- list
- get
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View File

@ -202,6 +202,14 @@ rules:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- list
- get
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View File

@ -202,6 +202,14 @@ rules:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- list
- get
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View File

@ -210,6 +210,14 @@ rules:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- list
- get
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View File

@ -207,6 +207,14 @@ rules:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- list
- get
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View File

@ -210,6 +210,14 @@ rules:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- list
- get
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View File

@ -210,6 +210,14 @@ rules:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- list
- get
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View File

@ -49,3 +49,7 @@ unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
allow-git = []
[sources.allow-org]
github = []

View File

@ -13,7 +13,11 @@ rustls-tls = ["kube/rustls-tls"]
[dependencies]
anyhow = "1"
async-trait = "0.1"
clap = { version = "3", default-features = false, features = ["derive", "env", "std"] }
clap = { version = "3", default-features = false, features = [
"derive",
"env",
"std",
] }
drain = "0.1"
futures = { version = "0.3", default-features = false }
k8s-gateway-api = "0.6"
@ -34,30 +38,16 @@ tracing = "0.1"
[dependencies.kube]
version = "0.74"
default-features = false
features = [
"admission",
"derive",
]
features = ["admission", "derive"]
[dependencies.kubert]
version = "0.9"
default-features = false
features = [
"clap",
"index",
"runtime",
"server",
]
features = ["clap", "index", "runtime", "server"]
[dependencies.tokio]
version = "1"
features = [
"macros",
"parking_lot",
"rt",
"rt-multi-thread",
"signal",
]
features = ["macros", "parking_lot", "rt", "rt-multi-thread", "signal"]
[target.x86_64-unknown-linux-gnu.dependencies]
jemallocator = "0.5"

View File

@ -10,4 +10,6 @@ ahash = "0.7"
anyhow = "1"
async-trait = "0.1"
futures = { version = "0.3", default-features = false, features = ["std"] }
http = "0.2"
ipnet = "2"
regex = "1"

View File

@ -0,0 +1,143 @@
use anyhow::Result;
pub use http::{
header::{HeaderName, HeaderValue},
uri::Scheme,
Method, StatusCode,
};
use regex::Regex;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InboundHttpRoute {
pub hostnames: Vec<HostMatch>,
pub rules: Vec<InboundHttpRouteRule>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HostMatch {
Exact(String),
Suffix { reverse_labels: Vec<String> },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InboundHttpRouteRule {
pub matches: Vec<HttpRouteMatch>,
pub filters: Vec<InboundFilter>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InboundFilter {
RequestHeaderModifier(RequestHeaderModifierFilter),
RequestRedirect(RequestRedirectFilter),
FailureInjector(FailureInjectorFilter),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RequestHeaderModifierFilter {
pub add: Vec<(HeaderName, HeaderValue)>,
pub set: Vec<(HeaderName, HeaderValue)>,
pub remove: Vec<HeaderName>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RequestRedirectFilter {
pub scheme: Option<Scheme>,
pub host: Option<String>,
pub path: Option<PathModifier>,
pub port: Option<u32>,
pub status: Option<StatusCode>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FailureInjectorFilter {
pub status: StatusCode,
pub message: String,
pub ratio: Ratio,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PathModifier {
Full(String),
Prefix(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Ratio {
pub numerator: u32,
pub denominator: u32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HttpRouteMatch {
pub path: Option<PathMatch>,
pub headers: Vec<HeaderMatch>,
pub query_params: Vec<QueryParamMatch>,
pub method: Option<Method>,
}
#[derive(Clone, Debug)]
pub enum PathMatch {
Exact(String),
Prefix(String),
Regex(Regex),
}
#[derive(Clone, Debug)]
pub enum HeaderMatch {
Exact(HeaderName, HeaderValue),
Regex(HeaderName, Regex),
}
#[derive(Clone, Debug)]
pub enum QueryParamMatch {
Exact(String, String),
Regex(String, Regex),
}
// === impl PathMatch ===
impl PartialEq for PathMatch {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Exact(l0), Self::Exact(r0)) => l0 == r0,
(Self::Prefix(l0), Self::Prefix(r0)) => l0 == r0,
(Self::Regex(l0), Self::Regex(r0)) => l0.as_str() == r0.as_str(),
_ => false,
}
}
}
impl Eq for PathMatch {}
impl PathMatch {
pub fn regex(s: &str) -> Result<Self> {
Ok(Self::Regex(Regex::new(s)?))
}
}
// === impl HeaderMatch ===
impl PartialEq for HeaderMatch {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Exact(n0, v0), Self::Exact(n1, v1)) => n0 == n1 && v0 == v1,
(Self::Regex(n0, r0), Self::Regex(n1, r1)) => n0 == n1 && r0.as_str() == r1.as_str(),
_ => false,
}
}
}
impl Eq for HeaderMatch {}
// === impl QueryParamMatch ===
impl PartialEq for QueryParamMatch {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Exact(n0, v0), Self::Exact(n1, v1)) => n0 == n1 && v0 == v1,
(Self::Regex(n0, r0), Self::Regex(n1, r1)) => n0 == n1 && r0.as_str() == r1.as_str(),
_ => false,
}
}
}
impl Eq for QueryParamMatch {}

View File

@ -1,10 +1,13 @@
#![deny(warnings, rust_2018_idioms)]
#![forbid(unsafe_code)]
pub mod http_route;
mod identity_match;
mod network_match;
pub use self::{identity_match::IdentityMatch, network_match::NetworkMatch};
pub use self::{
http_route::InboundHttpRoute, identity_match::IdentityMatch, network_match::NetworkMatch,
};
use ahash::AHashMap as HashMap;
use anyhow::Result;
use futures::prelude::*;
@ -28,6 +31,7 @@ pub struct InboundServer {
pub protocol: ProxyProtocol,
pub authorizations: HashMap<AuthorizationRef, ClientAuthorization>,
pub http_routes: HashMap<String, InboundHttpRoute>,
}
#[derive(Clone, Debug, PartialEq, Eq)]

View File

@ -0,0 +1,136 @@
use linkerd2_proxy_api::{http_route as proto, http_types};
use linkerd_policy_controller_core::http_route::{
FailureInjectorFilter, HeaderMatch, HostMatch, HttpRouteMatch, PathMatch, PathModifier,
QueryParamMatch, RequestHeaderModifierFilter, RequestRedirectFilter,
};
pub(crate) fn convert_host_match(h: HostMatch) -> proto::HostMatch {
proto::HostMatch {
r#match: Some(match h {
HostMatch::Exact(host) => proto::host_match::Match::Exact(host),
HostMatch::Suffix { reverse_labels } => {
proto::host_match::Match::Suffix(proto::host_match::Suffix {
reverse_labels: reverse_labels.to_vec(),
})
}
}),
}
}
pub(crate) fn convert_match(
HttpRouteMatch {
headers,
path,
query_params,
method,
}: HttpRouteMatch,
) -> proto::HttpRouteMatch {
let headers = headers
.into_iter()
.map(|hm| match hm {
HeaderMatch::Exact(name, value) => proto::HeaderMatch {
name: name.to_string(),
value: Some(proto::header_match::Value::Exact(value.as_bytes().to_vec())),
},
HeaderMatch::Regex(name, re) => proto::HeaderMatch {
name: name.to_string(),
value: Some(proto::header_match::Value::Regex(re.to_string())),
},
})
.collect();
let path = path.map(|path| proto::PathMatch {
kind: Some(match path {
PathMatch::Exact(path) => proto::path_match::Kind::Exact(path),
PathMatch::Prefix(prefix) => proto::path_match::Kind::Prefix(prefix),
PathMatch::Regex(regex) => proto::path_match::Kind::Regex(regex.to_string()),
}),
});
let query_params = query_params
.into_iter()
.map(|qpm| match qpm {
QueryParamMatch::Exact(name, value) => proto::QueryParamMatch {
name,
value: Some(proto::query_param_match::Value::Exact(value)),
},
QueryParamMatch::Regex(name, re) => proto::QueryParamMatch {
name,
value: Some(proto::query_param_match::Value::Regex(re.to_string())),
},
})
.collect();
proto::HttpRouteMatch {
headers,
path,
query_params,
method: method.map(Into::into),
}
}
pub(crate) fn convert_failure_injector_filter(
FailureInjectorFilter {
status,
message,
ratio,
}: FailureInjectorFilter,
) -> proto::HttpFailureInjector {
proto::HttpFailureInjector {
status: u32::from(status.as_u16()),
message,
ratio: Some(proto::Ratio {
numerator: ratio.numerator,
denominator: ratio.denominator,
}),
}
}
pub(crate) fn convert_header_modifier_filter(
RequestHeaderModifierFilter { add, set, remove }: RequestHeaderModifierFilter,
) -> proto::RequestHeaderModifier {
proto::RequestHeaderModifier {
add: Some(http_types::Headers {
headers: add
.into_iter()
.map(|(n, v)| http_types::headers::Header {
name: n.to_string(),
value: v.as_bytes().to_owned(),
})
.collect(),
}),
set: Some(http_types::Headers {
headers: set
.into_iter()
.map(|(n, v)| http_types::headers::Header {
name: n.to_string(),
value: v.as_bytes().to_owned(),
})
.collect(),
}),
remove: remove.into_iter().map(|n| n.to_string()).collect(),
}
}
pub(crate) fn convert_redirect_filter(
RequestRedirectFilter {
scheme,
host,
path,
port,
status,
}: RequestRedirectFilter,
) -> proto::RequestRedirect {
proto::RequestRedirect {
scheme: scheme.map(|ref s| s.into()),
host: host.unwrap_or_default(),
path: path.map(|pm| proto::PathModifier {
replace: Some(match pm {
PathModifier::Full(p) => proto::path_modifier::Replace::Full(p),
PathModifier::Prefix(p) => proto::path_modifier::Replace::Prefix(p),
}),
}),
port: port.unwrap_or_default(),
status: u32::from(status.unwrap_or_default().as_u16()),
}
}

View File

@ -1,6 +1,8 @@
#![deny(warnings, rust_2018_idioms)]
#![forbid(unsafe_code)]
mod http_route;
use futures::prelude::*;
use linkerd2_proxy_api::{
self as api,
@ -11,6 +13,7 @@ use linkerd2_proxy_api::{
meta::{metadata, Metadata},
};
use linkerd_policy_controller_core::{
http_route::{InboundFilter, InboundHttpRoute, InboundHttpRouteRule},
AuthorizationRef, ClientAuthentication, ClientAuthorization, DiscoverInboundServer,
IdentityMatch, InboundServer, InboundServerStream, IpNet, NetworkMatch, ProxyProtocol,
ServerRef,
@ -177,14 +180,30 @@ fn to_server(srv: &InboundServer, cluster_networks: &[IpNet]) -> proto::Server {
ProxyProtocol::Detect { timeout } => Some(proto::proxy_protocol::Kind::Detect(
proto::proxy_protocol::Detect {
timeout: Some(timeout.into()),
http_routes: Default::default(),
http_routes: srv
.http_routes
.iter()
.map(|(name, route)| to_http_route(name, route.clone()))
.collect(),
},
)),
ProxyProtocol::Http1 => Some(proto::proxy_protocol::Kind::Http1(
proto::proxy_protocol::Http1::default(),
proto::proxy_protocol::Http1 {
routes: srv
.http_routes
.iter()
.map(|(name, route)| to_http_route(name, route.clone()))
.collect(),
},
)),
ProxyProtocol::Http2 => Some(proto::proxy_protocol::Kind::Http2(
proto::proxy_protocol::Http2::default(),
proto::proxy_protocol::Http2 {
routes: srv
.http_routes
.iter()
.map(|(name, route)| to_http_route(name, route.clone()))
.collect(),
},
)),
ProxyProtocol::Grpc => Some(proto::proxy_protocol::Kind::Grpc(
proto::proxy_protocol::Grpc::default(),
@ -344,9 +363,62 @@ fn to_authz(
};
proto::Authz {
networks,
labels,
authentication: Some(authn),
metadata: Some(meta),
labels,
networks,
authentication: Some(authn),
}
}
fn to_http_route(
name: impl ToString,
InboundHttpRoute { hostnames, rules }: InboundHttpRoute,
) -> proto::HttpRoute {
let metadata = Metadata {
kind: Some(metadata::Kind::Resource(api::meta::Resource {
group: "gateway.networking.k8s.io".to_string(),
kind: "HTTPRoute".to_string(),
name: name.to_string(),
})),
};
let hosts = hostnames
.into_iter()
.map(http_route::convert_host_match)
.collect();
let rules = rules
.into_iter()
.map(
|InboundHttpRouteRule { matches, filters }| proto::http_route::Rule {
matches: matches.into_iter().map(http_route::convert_match).collect(),
filters: filters.into_iter().map(convert_filter).collect(),
},
)
.collect();
proto::HttpRoute {
metadata: Some(metadata),
hosts,
rules,
authorizations: Vec::default(), // TODO populate per-route authorizations
}
}
fn convert_filter(filter: InboundFilter) -> proto::http_route::Filter {
use proto::http_route::filter::Kind;
proto::http_route::Filter {
kind: Some(match filter {
InboundFilter::FailureInjector(f) => {
Kind::FailureInjector(http_route::convert_failure_injector_filter(f))
}
InboundFilter::RequestHeaderModifier(f) => {
Kind::RequestHeaderModifier(http_route::convert_header_modifier_filter(f))
}
InboundFilter::RequestRedirect(f) => {
Kind::Redirect(http_route::convert_redirect_filter(f))
}
}),
}
}

View File

@ -9,10 +9,12 @@ publish = false
ahash = "0.7"
anyhow = "1"
futures = { version = "0.3", default-features = false }
k8s-gateway-api = "0.6"
kubert = { version = "0.9", default-features = false, features = ["index"] }
linkerd-policy-controller-core = { path = "../../core" }
linkerd-policy-controller-k8s-api = { path = "../api" }
parking_lot = "0.12"
thiserror = "1"
tokio = { version = "1", features = ["macros", "rt", "sync"] }
tracing = "0.1"

View File

@ -0,0 +1,260 @@
use anyhow::{bail, Error, Result};
use k8s_gateway_api as api;
use linkerd_policy_controller_core::http_route;
#[derive(Clone, Debug, PartialEq)]
pub struct InboundRouteBinding {
pub parents: Vec<InboundParentRef>,
pub route: http_route::InboundHttpRoute,
}
#[derive(Clone, Debug, PartialEq)]
pub enum InboundParentRef {
Server(String),
}
#[derive(Clone, Debug, thiserror::Error)]
pub enum InvalidParentRef {
#[error("HTTPRoute resource does not reference a Server resource")]
DoesNotSelectServer,
#[error("HTTPRoute resource may not reference a parent Server in an other namespace")]
ServerInAnotherNamespace,
#[error("HTTPRoute resource may not reference a parent by port")]
SpecifiesPort,
#[error("HTTPRoute resource may not reference a parent by section name")]
SpecifiesSection,
}
impl TryFrom<api::HttpRoute> for InboundRouteBinding {
type Error = Error;
fn try_from(route: api::HttpRoute) -> Result<Self, Self::Error> {
let parents = route
.spec
.inner
.parent_refs
.into_iter()
.flatten()
.filter_map(
|api::ParentReference {
group,
kind,
namespace,
name,
section_name,
port,
}| {
// Ignore parents that are not a Server.
if let Some(g) = group {
if let Some(k) = kind {
if !g.eq_ignore_ascii_case("policy.linkerd.io")
|| !k.eq_ignore_ascii_case("server")
|| name.is_empty()
{
return None;
}
}
}
if namespace.is_some() && namespace != route.metadata.namespace {
return Some(Err(InvalidParentRef::ServerInAnotherNamespace));
}
if port.is_some() {
return Some(Err(InvalidParentRef::SpecifiesPort));
}
if section_name.is_some() {
return Some(Err(InvalidParentRef::SpecifiesSection));
}
Some(Ok(InboundParentRef::Server(name)))
},
)
.collect::<Result<Vec<_>, InvalidParentRef>>()?;
// If there are no valid parents, then the route is invalid.
if parents.is_empty() {
return Err(InvalidParentRef::DoesNotSelectServer.into());
}
let hostnames = route
.spec
.hostnames
.into_iter()
.flatten()
.map(|hostname| {
if hostname.starts_with("*.") {
let mut reverse_labels = hostname
.split('.')
.skip(1)
.map(|label| label.to_string())
.collect::<Vec<String>>();
reverse_labels.reverse();
http_route::HostMatch::Suffix { reverse_labels }
} else {
http_route::HostMatch::Exact(hostname)
}
})
.collect();
let rules = route
.spec
.rules
.into_iter()
.flatten()
.map(Self::try_rule)
.collect::<Result<_>>()?;
Ok(InboundRouteBinding {
parents,
route: http_route::InboundHttpRoute { hostnames, rules },
})
}
}
impl InboundRouteBinding {
#[inline]
pub fn selects_server(&self, name: &str) -> bool {
self.parents
.iter()
.any(|p| matches!(p, InboundParentRef::Server(n) if n == name))
}
fn try_match(
api::HttpRouteMatch {
path,
headers,
query_params,
method,
}: api::HttpRouteMatch,
) -> Result<http_route::HttpRouteMatch> {
let path = path
.map(|pm| match pm {
api::HttpPathMatch::Exact { value } => Ok(http_route::PathMatch::Exact(value)),
api::HttpPathMatch::PathPrefix { value } => {
Ok(http_route::PathMatch::Prefix(value))
}
api::HttpPathMatch::RegularExpression { value } => {
value.parse().map(http_route::PathMatch::Regex)
}
})
.transpose()?;
let headers = headers
.into_iter()
.flatten()
.map(|hm| match hm {
api::HttpHeaderMatch::Exact { name, value } => Ok(http_route::HeaderMatch::Exact(
name.parse()?,
value.parse()?,
)),
api::HttpHeaderMatch::RegularExpression { name, value } => Ok(
http_route::HeaderMatch::Regex(name.parse()?, value.parse()?),
),
})
.collect::<Result<_>>()?;
let query_params = query_params
.into_iter()
.flatten()
.map(|query_param| match query_param {
api::HttpQueryParamMatch::Exact { name, value } => {
Ok(http_route::QueryParamMatch::Exact(name, value))
}
api::HttpQueryParamMatch::RegularExpression { name, value } => {
Ok(http_route::QueryParamMatch::Exact(name, value.parse()?))
}
})
.collect::<Result<_>>()?;
let method = method
.as_deref()
.map(http_route::Method::try_from)
.transpose()?;
Ok(http_route::HttpRouteMatch {
path,
headers,
query_params,
method,
})
}
fn try_rule(rule: api::HttpRouteRule) -> Result<http_route::InboundHttpRouteRule> {
let matches = rule
.matches
.into_iter()
.flatten()
.map(Self::try_match)
.collect::<Result<_>>()?;
let filters = rule
.filters
.into_iter()
.flatten()
.map(Self::try_filter)
.collect::<Result<_>>()?;
Ok(http_route::InboundHttpRouteRule { matches, filters })
}
fn try_filter(filter: api::HttpRouteFilter) -> Result<http_route::InboundFilter> {
let filter = match filter {
api::HttpRouteFilter::RequestHeaderModifier {
request_header_modifier: api::HttpRequestHeaderFilter { set, add, remove },
} => http_route::InboundFilter::RequestHeaderModifier(
http_route::RequestHeaderModifierFilter {
add: add
.into_iter()
.flatten()
.map(|api::HttpHeader { name, value }| Ok((name.parse()?, value.parse()?)))
.collect::<Result<Vec<_>>>()?,
set: set
.into_iter()
.flatten()
.map(|api::HttpHeader { name, value }| Ok((name.parse()?, value.parse()?)))
.collect::<Result<Vec<_>>>()?,
remove: remove
.into_iter()
.flatten()
.map(http_route::HeaderName::try_from)
.collect::<Result<_, _>>()?,
},
),
api::HttpRouteFilter::RequestRedirect {
request_redirect:
api::HttpRequestRedirectFilter {
scheme,
hostname,
path,
port,
status_code,
},
} => http_route::InboundFilter::RequestRedirect(http_route::RequestRedirectFilter {
scheme: scheme.as_deref().map(TryInto::try_into).transpose()?,
host: hostname,
path: path.map(|path_mod| match path_mod {
api::HttpPathModifier::ReplaceFullPath(s) => http_route::PathModifier::Full(s),
api::HttpPathModifier::ReplacePrefixMatch(s) => {
http_route::PathModifier::Prefix(s)
}
}),
port: port.map(Into::into),
status: status_code.map(TryFrom::try_from).transpose()?,
}),
api::HttpRouteFilter::RequestMirror { .. } => {
bail!("RequestMirror filter is not supported")
}
api::HttpRouteFilter::URLRewrite { .. } => {
bail!("URLRewrite filter is not supported")
}
api::HttpRouteFilter::ExtensionRef { .. } => {
bail!("ExtensionRef filter is not supported")
}
};
Ok(filter)
}
}

View File

@ -7,14 +7,14 @@
//! kubernetes resources.
use crate::{
authorization_policy, defaults::DefaultPolicy, meshtls_authentication, network_authentication,
pod, server, server_authorization, ClusterInfo,
authorization_policy, defaults::DefaultPolicy, http_route::InboundRouteBinding,
meshtls_authentication, network_authentication, pod, server, server_authorization, ClusterInfo,
};
use ahash::{AHashMap as HashMap, AHashSet as HashSet};
use anyhow::{anyhow, bail, Result};
use linkerd_policy_controller_core::{
AuthorizationRef, ClientAuthentication, ClientAuthorization, IdentityMatch, InboundServer,
IpNet, Ipv4Net, Ipv6Net, NetworkMatch, ProxyProtocol, ServerRef,
AuthorizationRef, ClientAuthentication, ClientAuthorization, IdentityMatch, InboundHttpRoute,
InboundServer, IpNet, Ipv4Net, Ipv6Net, NetworkMatch, ProxyProtocol, ServerRef,
};
use linkerd_policy_controller_k8s_api::{self as k8s, policy::server::Port, ResourceExt};
use parking_lot::RwLock;
@ -110,6 +110,7 @@ struct PolicyIndex {
server_authorizations: HashMap<String, server_authorization::ServerAuthz>,
authorization_policies: HashMap<String, authorization_policy::Spec>,
http_routes: HashMap<String, InboundRouteBinding>,
}
#[derive(Debug, Default)]
@ -609,6 +610,88 @@ impl kubert::index::IndexNamespacedResource<k8s::policy::NetworkAuthentication>
}
}
impl kubert::index::IndexNamespacedResource<k8s_gateway_api::HttpRoute> for Index {
fn apply(&mut self, route: k8s_gateway_api::HttpRoute) {
let ns = route.namespace().expect("HttpRoute must have a namespace");
let name = route.name_unchecked();
let _span = info_span!("apply", %ns, %name).entered();
let route_binding = match route.try_into() {
Ok(binding) => binding,
Err(error) => {
tracing::info!(%ns, %name, %error, "Ignoring HTTPRoute");
return;
}
};
self.ns_or_default_with_reindex(ns, |ns| ns.policy.update_http_route(name, route_binding))
}
fn delete(&mut self, ns: String, name: String) {
let _span = info_span!("delete", %ns, %name).entered();
self.ns_with_reindex(ns, |ns| ns.policy.http_routes.remove(&name).is_some())
}
fn reset(
&mut self,
routes: Vec<k8s_gateway_api::HttpRoute>,
deleted: HashMap<String, HashSet<String>>,
) {
let _span = info_span!("reset").entered();
// Aggregate all of the updates by namespace so that we only reindex
// once per namespace.
type Ns = NsUpdate<InboundRouteBinding>;
let mut updates_by_ns = HashMap::<String, Ns>::default();
for route in routes.into_iter() {
let namespace = route.namespace().expect("HttpRoute must be namespaced");
let name = route.name_unchecked();
let route_binding = match route.try_into() {
Ok(binding) => binding,
Err(error) => {
tracing::info!(ns = %namespace, %name, %error, "Ignoring HTTPRoute");
continue;
}
};
updates_by_ns
.entry(namespace)
.or_default()
.added
.push((name, route_binding));
}
for (ns, names) in deleted.into_iter() {
updates_by_ns.entry(ns).or_default().removed = names;
}
for (namespace, Ns { added, removed }) in updates_by_ns.into_iter() {
if added.is_empty() {
// If there are no live resources in the namespace, we do not
// want to create a default namespace instance, we just want to
// clear out all resources for the namespace (and then drop the
// whole namespace, if necessary).
self.ns_with_reindex(namespace, |ns| {
ns.policy.http_routes.clear();
true
});
} else {
// Otherwise, we take greater care to reindex only when the
// state actually changed. The vast majority of resets will see
// no actual data change.
self.ns_or_default_with_reindex(namespace, |ns| {
let mut changed = !removed.is_empty();
for name in removed.into_iter() {
ns.policy.http_routes.remove(&name);
}
for (name, route_binding) in added.into_iter() {
changed = ns.policy.update_http_route(name, route_binding) || changed;
}
changed
});
}
}
}
}
// === impl NemspaceIndex ===
impl NamespaceIndex {
@ -670,6 +753,7 @@ impl Namespace {
servers: HashMap::default(),
server_authorizations: HashMap::default(),
authorization_policies: HashMap::default(),
http_routes: HashMap::default(),
},
}
}
@ -941,6 +1025,7 @@ impl Pod {
reference: ServerRef::Default(policy.to_string()),
protocol,
authorizations,
http_routes: HashMap::default(),
}
}
}
@ -1015,10 +1100,13 @@ impl PolicyIndex {
) -> InboundServer {
tracing::trace!(%name, ?server, "Creating inbound server");
let authorizations = self.client_authzs(&name, server, authentications);
let routes = self.http_routes(&name);
InboundServer {
reference: ServerRef::Server(name),
authorizations,
protocol: server.protocol.clone(),
http_routes: routes,
}
}
@ -1084,6 +1172,14 @@ impl PolicyIndex {
authzs
}
fn http_routes(&self, server_name: &str) -> HashMap<String, InboundHttpRoute> {
self.http_routes
.iter()
.filter(|(_, route)| route.selects_server(server_name))
.map(|(name, route)| (name.clone(), route.route.clone()))
.collect()
}
fn policy_client_authz(
&self,
spec: &authorization_policy::Spec,
@ -1188,6 +1284,21 @@ impl PolicyIndex {
}),
})
}
fn update_http_route(&mut self, name: String, route: InboundRouteBinding) -> bool {
match self.http_routes.entry(name) {
Entry::Vacant(entry) => {
entry.insert(route);
}
Entry::Occupied(mut entry) => {
if *entry.get() == route {
return false;
}
entry.insert(route);
}
}
true
}
}
// === impl AuthenticationNsIndex ===

View File

@ -25,6 +25,7 @@
pub mod authorization_policy;
mod defaults;
mod http_route;
mod index;
mod meshtls_authentication;
mod network_authentication;

View File

@ -1,5 +1,6 @@
mod annotation;
mod authorization_policy;
mod http_routes;
mod server_authorization;
use crate::{defaults::DefaultPolicy, index::*, server_authorization::ServerSelector, ClusterInfo};
@ -195,6 +196,7 @@ impl TestConfig {
protocol: ProxyProtocol::Detect {
timeout: self.detect_timeout,
},
http_routes: HashMap::default(),
}
}

View File

@ -119,6 +119,7 @@ fn authenticated_annotated() {
protocol: ProxyProtocol::Detect {
timeout: test.detect_timeout,
},
http_routes: HashMap::default(),
}
};

View File

@ -31,6 +31,7 @@ fn links_authorization_policy_with_mtls_name() {
reference: ServerRef::Server("srv-8080".to_string()),
authorizations: Default::default(),
protocol: ProxyProtocol::Http1,
http_routes: HashMap::default(),
},
);
@ -84,6 +85,7 @@ fn links_authorization_policy_with_mtls_name() {
.into_iter()
.collect(),
protocol: ProxyProtocol::Http1,
http_routes: HashMap::default(),
},
);
}
@ -119,6 +121,7 @@ fn authorization_targets_namespace() {
reference: ServerRef::Server("srv-8080".to_string()),
authorizations: Default::default(),
protocol: ProxyProtocol::Http1,
http_routes: HashMap::default(),
},
);
@ -172,6 +175,7 @@ fn authorization_targets_namespace() {
.into_iter()
.collect(),
protocol: ProxyProtocol::Http1,
http_routes: HashMap::default(),
},
);
}
@ -207,6 +211,7 @@ fn links_authorization_policy_with_service_account() {
reference: ServerRef::Server("srv-8080".to_string()),
authorizations: Default::default(),
protocol: ProxyProtocol::Http1,
http_routes: HashMap::default(),
},
);
@ -254,6 +259,7 @@ fn links_authorization_policy_with_service_account() {
.into_iter()
.collect(),
protocol: ProxyProtocol::Http1,
http_routes: HashMap::default(),
},
);
}

View File

@ -0,0 +1,86 @@
use super::*;
#[test]
fn route_attaches_to_server() {
let test = TestConfig::default();
let mut pod = mk_pod("ns-0", "pod-0", Some(("container-0", None)));
pod.labels_mut()
.insert("app".to_string(), "app-0".to_string());
test.index.write().apply(pod);
let mut rx = test
.index
.write()
.pod_server_rx("ns-0", "pod-0", 8080)
.expect("pod-0.ns-0 should exist");
assert_eq!(*rx.borrow_and_update(), test.default_server());
test.index.write().apply(mk_server(
"ns-0",
"srv-8080",
Port::Number(8080),
Some(("app", "app-0")),
Some(("app", "app-0")),
Some(k8s::policy::server::ProxyProtocol::Http1),
));
assert!(rx.has_changed().unwrap());
assert_eq!(
*rx.borrow_and_update(),
InboundServer {
reference: ServerRef::Server("srv-8080".to_string()),
authorizations: Default::default(),
protocol: ProxyProtocol::Http1,
http_routes: HashMap::default(),
},
);
test.index
.write()
.apply(mk_http_route("ns-0", "route-foo", "srv-8080"));
assert!(rx.has_changed().unwrap());
assert_eq!(
rx.borrow().reference,
ServerRef::Server("srv-8080".to_string())
);
assert!(rx.borrow().http_routes.contains_key("route-foo"));
}
fn mk_http_route(
ns: impl ToString,
name: impl ToString,
server: impl ToString,
) -> k8s_gateway_api::HttpRoute {
k8s_gateway_api::HttpRoute {
metadata: k8s::ObjectMeta {
namespace: Some(ns.to_string()),
name: Some(name.to_string()),
..Default::default()
},
spec: k8s_gateway_api::HttpRouteSpec {
inner: k8s_gateway_api::CommonRouteSpec {
parent_refs: Some(vec![k8s_gateway_api::ParentReference {
group: Some("policy.linkerd.io".to_string()),
kind: Some("Server".to_string()),
namespace: None,
name: server.to_string(),
section_name: None,
port: None,
}]),
},
hostnames: None,
rules: Some(vec![k8s_gateway_api::HttpRouteRule {
matches: Some(vec![k8s_gateway_api::HttpRouteMatch {
path: Some(k8s_gateway_api::HttpPathMatch::PathPrefix {
value: "/foo/bar".to_string(),
}),
headers: None,
query_params: None,
method: Some("GET".to_string()),
}]),
filters: None,
backend_refs: None,
}]),
},
status: None,
}
}

View File

@ -43,6 +43,7 @@ fn link_server_authz(selector: ServerSelector) {
reference: ServerRef::Server("srv-8080".to_string()),
authorizations: Default::default(),
protocol: ProxyProtocol::Http1,
http_routes: HashMap::default(),
},
);
test.index.write().apply(mk_server_authz(

View File

@ -450,30 +450,14 @@ fn validate_http_route_rule(rule: &HttpRouteRule) -> Result<()> {
}
fn validate_http_route_filters(filters: &[HttpRouteFilter]) -> Result<()> {
// for filter in filters.iter() {
// match filter {
// HttpRouteFilter::ExtensionRef{ .. } => bail!("ExtensionRef filters are not supported"),
// HttpRouteFilter::RequestHeaderModifier { .. } => bail!("RequestHeaderModifier filters are not supported"),
// HttpRouteFilter::RequestMirror { .. } => bail!("RequestMirror filters are not supported"),
// HttpRouteFilter::RequestRedirect { .. } => bail!("RequestRedirect filters are not supported"),
// HttpRouteFilter::URLRewrite { .. } => bail!("URLRewrite filters are not supported"),
// }
// }
// Since we don't support any filter types yet, we will always fail on the
// first filter. Once it becomes possible for this validation to pass, we
// should iterate through all filters, as in the commented-out code above.
if let Some(filter) = filters.iter().next() {
for filter in filters.iter() {
match filter {
HttpRouteFilter::ExtensionRef { .. } => bail!("ExtensionRef filters are not supported"),
HttpRouteFilter::RequestHeaderModifier { .. } => {
bail!("RequestHeaderModifier filters are not supported")
}
HttpRouteFilter::RequestHeaderModifier { .. } => {}
HttpRouteFilter::RequestMirror { .. } => {
bail!("RequestMirror filters are not supported")
}
HttpRouteFilter::RequestRedirect { .. } => {
bail!("RequestRedirect filters are not supported")
}
HttpRouteFilter::RequestRedirect { .. } => {}
HttpRouteFilter::URLRewrite { .. } => bail!("URLRewrite filters are not supported"),
}
}

View File

@ -146,6 +146,11 @@ async fn main() -> Result<()> {
.instrument(info_span!("networkauthentications")),
);
let http_routes = runtime.watch_all::<k8s_gateway_api::HttpRoute>(ListParams::default());
tokio::spawn(
kubert::index::namespaced(index.clone(), http_routes).instrument(info_span!("httproutes")),
);
// Run the gRPC server, serving results by looking up against the index handle.
tokio::spawn(grpc(
grpc_addr,

View File

@ -58,7 +58,7 @@ async fn server_with_server_authorization() {
)),
);
// Create a server authorizaation that refers to the `linkerd-admin`
// Create a server authorization that refers to the `linkerd-admin`
// server (by name) and ensure that the update now reflects this
// authorization.
create(
@ -286,6 +286,207 @@ async fn server_with_authorization_policy() {
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn server_with_http_route() {
with_temp_ns(|client, ns| async move {
// Create a pod that does nothing. It's injected with a proxy, so we can
// attach policies to its admin server.
let pod = create_ready_pod(&client, mk_pause(&ns, "pause")).await;
tracing::trace!(?pod);
let mut rx = retry_watch_server(&client, &ns, &pod.name_unchecked()).await;
let config = rx
.next()
.await
.expect("watch must not fail")
.expect("watch must return an initial config");
tracing::trace!(?config);
assert_is_default_all_unauthenticated!(config);
assert_protocol_detect!(config);
// Create a server that selects the pod's proxy admin server and ensure
// that the update now uses this server, which has no authorizations
// and no routes.
let _server = create(&client, mk_admin_server(&ns, "linkerd-admin")).await;
let config = rx
.next()
.await
.expect("watch must not fail")
.expect("watch must return an updated config");
tracing::trace!(?config);
assert_eq!(
config.protocol,
Some(grpc::inbound::ProxyProtocol {
kind: Some(grpc::inbound::proxy_protocol::Kind::Http1(
grpc::inbound::proxy_protocol::Http1::default()
)),
}),
);
assert_eq!(config.authorizations, vec![]);
assert_eq!(
config.labels,
convert_args!(hashmap!(
"group" => "policy.linkerd.io",
"kind" => "server",
"name" => "linkerd-admin"
)),
);
// Create an http route that refers to the `linkerd-admin` server (by
// name) and ensure that the update now reflects this route.
create(
&client,
k8s_gateway_api::HttpRoute {
metadata: kube::api::ObjectMeta {
namespace: Some(ns.clone()),
name: Some("metrics-route".to_string()),
..Default::default()
},
spec: k8s_gateway_api::HttpRouteSpec {
inner: k8s_gateway_api::CommonRouteSpec {
parent_refs: Some(vec![k8s_gateway_api::ParentReference {
group: Some("policy.linkerd.io".to_string()),
kind: Some("Server".to_string()),
namespace: None,
name: "linkerd-admin".to_string(),
section_name: None,
port: None,
}]),
},
hostnames: None,
rules: Some(vec![k8s_gateway_api::HttpRouteRule {
matches: Some(vec![k8s_gateway_api::HttpRouteMatch {
path: Some(k8s_gateway_api::HttpPathMatch::Exact {
value: "/metrics".to_string(),
}),
headers: None,
query_params: None,
method: Some("GET".to_string()),
}]),
filters: None,
backend_refs: None,
}]),
},
status: None,
},
)
.await;
let config = rx
.next()
.await
.expect("watch must not fail")
.expect("watch must return an updated config");
tracing::trace!(?config);
let http1 = if let grpc::inbound::proxy_protocol::Kind::Http1(http1) = config
.protocol
.expect("must have proxy protocol")
.kind
.expect("must have kind")
{
http1
} else {
panic!("proxy protocol must be HTTP1")
};
let route = http1.routes.first().expect("must have route");
let rule_match = route
.rules
.first()
.expect("must have rule")
.matches
.first()
.expect("must have match");
// Route has no authorizations by default.
assert_eq!(route.authorizations, Vec::default());
// Route has appropriate metadata.
assert_eq!(
route.metadata.to_owned().expect("route must have metadata"),
grpc::meta::Metadata {
kind: Some(grpc::meta::metadata::Kind::Resource(grpc::meta::Resource {
group: "gateway.networking.k8s.io".to_string(),
kind: "HTTPRoute".to_string(),
name: "metrics-route".to_string(),
}))
}
);
// Route has path match.
assert_eq!(
rule_match
.path
.to_owned()
.expect("must have path match")
.kind
.expect("must have kind"),
grpc::http_route::path_match::Kind::Exact("/metrics".to_string()),
);
// Create a server authorization that refers to the `linkerd-admin`
// server (by name) and ensure that the authorization is copied onto
// the route.
create(
&client,
k8s::policy::ServerAuthorization {
metadata: kube::api::ObjectMeta {
namespace: Some(ns.clone()),
name: Some("all-admin".to_string()),
..Default::default()
},
spec: k8s::policy::ServerAuthorizationSpec {
server: k8s::policy::server_authorization::Server {
name: Some("linkerd-admin".to_string()),
selector: None,
},
client: k8s::policy::server_authorization::Client {
unauthenticated: true,
..k8s::policy::server_authorization::Client::default()
},
},
},
)
.await;
let config = rx
.next()
.await
.expect("watch must not fail")
.expect("watch must return an updated config");
tracing::trace!(?config);
let http1 = if let grpc::inbound::proxy_protocol::Kind::Http1(http1) = config
.protocol
.expect("must have proxy protocol")
.kind
.expect("must have kind")
{
http1
} else {
panic!("proxy protocol must be HTTP1")
};
assert_eq!(http1.routes.len(), 1, "must have routes");
// Delete the `HttpRoute` and ensure that the update reverts to the
// default.
kube::Api::<k8s_gateway_api::HttpRoute>::namespaced(client.clone(), &ns)
.delete("metrics-route", &kube::api::DeleteParams::default())
.await
.expect("HttpRoute must be deleted");
let config = rx
.next()
.await
.expect("watch must not fail")
.expect("watch must return an updated config");
tracing::trace!(?config);
assert_eq!(
config.protocol,
Some(grpc::inbound::ProxyProtocol {
kind: Some(grpc::inbound::proxy_protocol::Kind::Http1(
grpc::inbound::proxy_protocol::Http1::default()
)),
}),
);
})
.await;
}
fn mk_pause(ns: &str, name: &str) -> k8s::Pod {
k8s::Pod {
metadata: k8s::ObjectMeta {