-
Notifications
You must be signed in to change notification settings - Fork 270
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
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>, | ||
|
||
/// 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) }) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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()) | ||
); | ||
} | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.