Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(tls-route): add TLS route matching crate #3192

Merged
merged 3 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Cargo.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2190,6 +2190,18 @@ dependencies = [
"untrusted",
]

[[package]]
name = "linkerd-tls-route"
version = "0.1.0"
dependencies = [
"linkerd-dns",
"linkerd-tls",
"rand",
"regex",
"thiserror",
"tracing",
]

[[package]]
name = "linkerd-tls-test-util"
version = "0.1.0"
Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ members = [
"linkerd/tonic-stream",
"linkerd/tonic-watch",
"linkerd/tls",
"linkerd/tls/route",
"linkerd/tls/test-util",
"linkerd/tracing",
"linkerd/transport-header",
Expand Down
14 changes: 14 additions & 0 deletions linkerd/tls/route/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[package]
name = "linkerd-tls-route"
version = "0.1.0"
license = "Apache-2.0"
edition = "2021"
publish = false

[dependencies]
regex = "1"
rand = "0.8"
thiserror = "1"
tracing = "0.1"
linkerd-tls = { path = "../" }
linkerd-dns = { path = "../../dns" }
106 changes: 106 additions & 0 deletions linkerd/tls/route/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//! An TLS route matching library for Linkerd to support the TLSRoute
//! Kubernetes Gateway API types.

#![deny(rust_2018_idioms, clippy::disallowed_methods, clippy::disallowed_types)]
#![forbid(unsafe_code)]

use linkerd_tls::ServerName;
use r#match::SessionMatch;
use tracing::trace;

pub mod r#match;
pub mod sni;
#[cfg(test)]
mod tests;

pub use self::sni::{InvalidSni, MatchSni, SniMatch};

/// Groups routing rules under a common set of SNIs.
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct Route<P> {
/// A list of SNIs that this route applies to, to be matched against,
///
/// If at least one match is specified, any match may apply for rules to applied.
/// When no SNI matches are present, all SNIs match.
pub snis: Vec<MatchSni>,

/// Must not be empty.
pub rules: Vec<Rule<P>>,
}

/// Policies for a given set of route matches.
#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)]
pub struct Rule<P> {
/// A list of session matchers, *any* of which may apply.
///
/// The "best" match is used when comparing rules.
pub matches: Vec<r#match::MatchSession>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So it seems that we have both SNI matching at the Route level and also the Rule level. If we're going to include MatchSni at the Route level, then we probably want MatchSession to be empty for now. Otherwise, we probably want to omit the sni matching at the Route level.

I don't have enough information to suggest one or the other. I don't really see any harm in it being defined at the Route level (since this matches the gateway spec). Given that the spec does not include any rule based matches currently, I'm not sure of a good reason to include SNI matches in rules.


/// The policy to apply to sessions matched by this rule.
pub policy: P,
}

/// Summarizes a matched route so that route matches may be compared/ordered. A
/// greater match is preferred over a lesser match.
#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Default)]
pub struct RouteMatch {
sni: Option<SniMatch>,
route: r#match::SessionMatch,
}

/// Provides metadata information about a TLS session. For now this contains
/// only the SNI value but further down the line, we could add more metadata
/// if want to support more advanced routing scenarios.
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct SessionInfo {
pub sni: ServerName,
}

pub fn find<P>(routes: &[Route<P>], session_info: SessionInfo) -> Option<(RouteMatch, &P)> {
trace!(routes = ?routes.len(), "Finding matching route");

best(routes.iter().filter_map(|rt| {
trace!(snis = ?rt.snis);
let sni = if rt.snis.is_empty() {
None
} else {
let session_sni = &session_info.sni;
trace!(%session_sni, "matching sni");
let sni_match = rt
.snis
.iter()
.filter_map(|a| a.summarize_match(session_sni))
.max()?;
Some(sni_match)
};

trace!(rules = %rt.rules.len());
let (route, policy) = best(rt.rules.iter().filter_map(|rule| {
// If there are no matches in the list, then the rule has an
// implicit default match.
if rule.matches.is_empty() {
trace!("implicit match");
return Some((SessionMatch::default(), &rule.policy));
}
// Find the best match to compare against other rules/routes
// (if any apply). The order/precedence of matches is not
// relevant.
let summary = rule
.matches
.iter()
.filter_map(|m| m.match_session(&session_info))
.max()?;
trace!("matches!");
Some((summary, &rule.policy))
}))?;

Some((RouteMatch { sni, route }, policy))
}))
}

#[inline]
fn best<M: Ord, P>(matches: impl Iterator<Item = (M, P)>) -> Option<(M, P)> {
// This is roughly equivalent to `max_by(...)` but we want to ensure
// that the first match wins.
matches.reduce(|(m0, p0), (m1, p1)| if m0 >= m1 { (m0, p0) } else { (m1, p1) })
}
29 changes: 29 additions & 0 deletions linkerd/tls/route/src/match.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
use std::cmp::Ordering;

use crate::SessionInfo;

/// Matches TLS sessions. For now, this is a placeholder
#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)]
pub struct MatchSession(());

/// Summarizes a matched TLS session. For now this is a placeholder
#[derive(Clone, Debug, Hash, PartialEq, Eq, Default)]
pub struct SessionMatch(());

impl MatchSession {
pub(crate) fn match_session(&self, _: &SessionInfo) -> Option<SessionMatch> {
Some(SessionMatch::default())
}
}

impl std::cmp::PartialOrd for SessionMatch {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}

impl std::cmp::Ord for SessionMatch {
fn cmp(&self, _: &Self) -> std::cmp::Ordering {
Ordering::Equal
}
}
191 changes: 191 additions & 0 deletions linkerd/tls/route/src/sni.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
use linkerd_dns as dns;
use linkerd_tls::ServerName;

/// Defines a way to match against SNI attributes of the TLS ClientHello
/// message in a TLS handshake. The SNI value being matched is the equivalent
/// of a hostname (as defined in RFC 1123) with 2 notable exceptions:
///
/// 1. IPs are not allowed in SNI names per RFC 6066.
/// 2. A hostname may be prefixed with a wildcard label (`*.`). The wildcard
/// label must appear by itself as the first label.
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub enum MatchSni {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably worth documenting SNI matching semantics with some of the Gateway API's verbiage.

Exact(String),

/// Tokenized reverse list of DNS name suffix labels.
///
/// For example: the match `*.example.com` is stored as `["com",
/// "example"]`.
Suffix(Vec<String>),
}

#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub enum SniMatch {
Exact(usize),
Suffix(usize),
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
pub enum InvalidSni {
#[error("invalid sni: {0}")]
Invalid(#[from] dns::InvalidName),
}

// === impl MatchSni ===

impl std::str::FromStr for MatchSni {
type Err = InvalidSni;

fn from_str(sni: &str) -> Result<Self, Self::Err> {
if let Some(sni) = sni.strip_prefix("*.") {
return Ok(Self::Suffix(
sni.split('.').map(|s| s.to_string()).rev().collect(),
));
}

Ok(Self::Exact(sni.to_string()))
}
}

impl MatchSni {
pub fn summarize_match(&self, sni: &ServerName) -> Option<SniMatch> {
let mut sni = sni.as_str();

match self {
Self::Exact(h) => {
if !h.ends_with('.') {
sni = sni.strip_suffix('.').unwrap_or(sni);
}
if h == sni {
Some(SniMatch::Exact(h.len()))
} else {
None
}
}

Self::Suffix(suffix) => {
if suffix.first().map(|s| &**s) != Some("") {
sni = sni.strip_suffix('.').unwrap_or(sni);
}
let mut length = 0;
for sfx in suffix.iter() {
sni = sni.strip_suffix(sfx)?;
sni = sni.strip_suffix('.')?;
length += sfx.len() + 1;
}

Some(SniMatch::Suffix(length))
}
}
}
}

// === impl SniMatch ===

impl std::cmp::PartialOrd for SniMatch {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}

impl std::cmp::Ord for SniMatch {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
use std::cmp::Ordering;
match (self, other) {
(Self::Exact(l), Self::Exact(r)) => l.cmp(r),
(Self::Suffix(l), Self::Suffix(r)) => l.cmp(r),
(Self::Exact(_), Self::Suffix(_)) => Ordering::Greater,
(Self::Suffix(_), Self::Exact(_)) => Ordering::Less,
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn exact() {
let m = "example.com"
.parse::<MatchSni>()
.expect("example.com parses");
assert_eq!(m, MatchSni::Exact("example.com".to_string()));
assert_eq!(
m.summarize_match(&"example.com".parse().unwrap()),
Some(SniMatch::Exact("example.com".len()))
);
assert_eq!(
m.summarize_match(&"example.com.".parse().unwrap()),
Some(SniMatch::Exact("example.com".len()))
);
assert_eq!(m.summarize_match(&"foo.example.com".parse().unwrap()), None);

let m = "example.com."
.parse::<MatchSni>()
.expect("example.com parses");
assert_eq!(m, MatchSni::Exact("example.com.".to_string()));
assert_eq!(m.summarize_match(&"example.com".parse().unwrap()), None,);
assert_eq!(
m.summarize_match(&"example.com.".parse().unwrap()),
Some(SniMatch::Exact("example.com.".len()))
);
}

#[test]
fn suffix() {
let m = "*.example.com"
.parse::<MatchSni>()
.expect("*.example.com parses");
assert_eq!(
m,
MatchSni::Suffix(vec!["com".to_string(), "example".to_string()])
);

assert_eq!(m.summarize_match(&"example.com".parse().unwrap()), None);
assert_eq!(
m.summarize_match(&"foo.example.com".parse().unwrap()),
Some(SniMatch::Suffix(".example.com".len()))
);
assert_eq!(
m.summarize_match(&"foo.example.com".parse().unwrap()),
Some(SniMatch::Suffix(".example.com".len()))
);
assert_eq!(
m.summarize_match(&"bar.foo.example.com".parse().unwrap()),
Some(SniMatch::Suffix(".example.com".len()))
);

let m = "*.example.com."
.parse::<MatchSni>()
.expect("*.example.com. parses");
assert_eq!(
m,
MatchSni::Suffix(vec![
"".to_string(),
"com".to_string(),
"example".to_string()
])
);
assert_eq!(
m.summarize_match(&"bar.foo.example.com".parse().unwrap()),
None
);
assert_eq!(
m.summarize_match(&"bar.foo.example.com.".parse().unwrap()),
Some(SniMatch::Suffix(".example.com.".len()))
);
}

#[test]
fn cmp() {
assert!(SniMatch::Exact("example.com".len()) > SniMatch::Suffix(".example.com".len()));
assert!(SniMatch::Exact("foo.example.com".len()) > SniMatch::Exact("example.com".len()));
assert!(
SniMatch::Suffix(".foo.example.com".len()) > SniMatch::Suffix(".example.com".len())
);
assert_eq!(
SniMatch::Suffix(".foo.example.com".len()),
SniMatch::Suffix(".bar.example.com".len())
);
}
}
Loading
Loading