This commit is contained in:
Mateusz Gruszczyński committed 2026-10-04 11:15:58 +02:00
1 parent 87e571c170
commit fa7abc9e53
308 files changed
+49746 -50237

No files matched your search

+411
View File
@@ -0,0 +1,411 @@
impl Snapshot {
fn host_patterns(host: &str) -> Vec<String> {
let mut patterns = vec![host.to_owned()];
for (index, _) in host.match_indices('.') {
let suffix = &host[index + 1..];
if suffix.contains('.') {
patterns.push(format!("*.{suffix}"));
}
}
patterns
}
pub fn host_allowed(&self, host: &str, peer: IpAddr) -> bool {
for pattern in Self::host_patterns(host) {
if let Some(acl) = self.host_acls.get(&pattern) {
return acl.allows(peer);
}
}
true
}
/// RFC 8441 SETTINGS_ENABLE_CONNECT_PROTOCOL is connection-scoped, so it is
/// advertised only when at least one explicitly opted-in vhost is bound to
/// this HTTP/2 listener. Request routing still enforces the per-vhost opt-in.
pub fn websocket_rfc8441_on(&self, local: SocketAddr, tls: bool) -> bool {
self.frontends.values().flatten().any(|frontend| {
frontend.websockets
&& frontend.websocket_rfc8441
&& frontend.http2_enabled
&& frontend.bindings.iter().any(|binding| {
binding.matches(local, tls)
&& if tls { binding.http2 } else { binding.h2c }
})
})
}
/// RFC 9220 SETTINGS_ENABLE_CONNECT_PROTOCOL is connection-scoped, so it is
/// advertised only when at least one explicitly opted-in vhost is bound to
/// this QUIC listener. Request routing still enforces the per-vhost opt-in.
pub fn websocket_rfc9220_on(&self, local: SocketAddr) -> bool {
self.frontends.values().flatten().any(|frontend| {
frontend.websockets
&& frontend.websocket_rfc9220
&& frontend.http3_enabled
&& frontend.bindings.iter().any(|binding| {
binding.tls && binding.http3.is_some() && binding.matches(local, true)
})
})
}
/// Production routing always uses the TCP connection's local endpoint.
/// Exact hostnames win over the longest matching wildcard hostname.
pub fn select_on(
&self,
host: &str,
path: &str,
local: SocketAddr,
tls: bool,
) -> Option<&CompiledRoute> {
for pattern in Self::host_patterns(host) {
let fallback = self.fallbacks.get(&pattern).and_then(|routes| {
routes
.iter()
.find(|route| route.bindings.iter().any(|l| l.matches(local, tls)))
});
// A claimed exact/wildcard vhost owns the request as a unit. First try
// its Custom locations, then its main backend fallback; never continue
// into a broader wildcard merely because no Custom location matched.
if let Some(fallback) = fallback {
return self
.routes
.get(&pattern)
.and_then(|routes| {
select_route(routes, path, |r| {
r.bindings.iter().any(|l| l.matches(local, tls))
})
})
.or(Some(fallback));
}
}
None
}
pub fn frontend(
&self,
host: &str,
local: SocketAddr,
tls: bool,
) -> Option<&Arc<CompiledFrontend>> {
for pattern in Self::host_patterns(host) {
if let Some(frontend) = self.frontends.get(&pattern).and_then(|frontends| {
frontends
.iter()
.find(|f| f.bindings.iter().any(|l| l.matches(local, tls)))
}) {
return Some(frontend);
}
}
None
}
/// Select a redirect host/rule using the accepted socket, TLS state and normalized path.
/// Exact hostnames win over the longest matching wildcard, matching vhost behavior.
pub fn redirect_on(
&self,
host: &str,
path: &str,
local: SocketAddr,
tls: bool,
) -> Option<(
&Arc<crate::redirect::CompiledHost>,
&crate::redirect::CompiledRule,
)> {
for pattern in Self::host_patterns(host) {
if let Some((redirect, rule)) = self.redirects.get(&pattern).and_then(|redirects| {
redirects.iter().find_map(|redirect| {
if !redirect
.bindings
.iter()
.any(|binding| binding.matches(local, tls))
{
return None;
}
redirect
.rules
.iter()
.find(|rule| rule.matches(path, tls))
.map(|rule| (redirect, rule))
})
}) {
return Some((redirect, rule));
}
}
None
}
/// Select a host-owned local 404/static response using the accepted socket.
pub fn not_found_host_on(
&self,
host: &str,
local: SocketAddr,
tls: bool,
) -> Option<&Arc<crate::not_found::CompiledHost>> {
for pattern in Self::host_patterns(host) {
if let Some(item) = self.not_found_hosts.get(&pattern).and_then(|items| {
items.iter().find(|item| {
item.bindings
.iter()
.any(|binding| binding.matches(local, tls))
})
}) {
return Some(item);
}
}
None
}
/// Legacy inspection helper only; network handlers must call select_on.
pub fn select(&self, host: &str, path: &str) -> Option<&CompiledRoute> {
for pattern in Self::host_patterns(host) {
if let Some(fallback) = self
.fallbacks
.get(&pattern)
.and_then(|routes| routes.first())
{
return self
.routes
.get(&pattern)
.and_then(|routes| select_route(routes, path, |_| true))
.or(Some(fallback));
}
}
None
}
}
fn select_route<'a, F>(
routes: &'a [CompiledRoute],
path: &str,
allowed: F,
) -> Option<&'a CompiledRoute>
where
F: Fn(&CompiledRoute) -> bool,
{
if let Some(exact) = routes.iter().find(|route| {
allowed(route)
&& route.match_type == crate::vhost::PathMatch::Exact
&& route.path_prefix == path
}) {
return Some(exact);
}
let prefix = routes
.iter()
.filter(|route| {
allowed(route)
&& matches!(
route.match_type,
crate::vhost::PathMatch::Prefix | crate::vhost::PathMatch::PrefixPriority
)
&& prefix_matches(&route.path_prefix, path)
})
.max_by_key(|route| route.path_prefix.len());
if prefix.is_some_and(|route| route.match_type == crate::vhost::PathMatch::PrefixPriority) {
return prefix;
}
if let Some(regex) = routes.iter().find(|route| {
allowed(route)
&& matches!(
route.match_type,
crate::vhost::PathMatch::Regex | crate::vhost::PathMatch::RegexInsensitive
)
&& route
.path_regex
.as_ref()
.is_some_and(|regex| regex.is_match(path))
}) {
return Some(regex);
}
prefix
}
impl CompiledRoute {
/// Return a normalized replacement path for the upstream hop. `None` means
/// preserve the client's exact origin-form path bytes.
pub fn rewritten_path(&self, path: &str) -> Result<Option<String>> {
use crate::vhost::RewriteMode;
let rewritten = match self.rewrite.mode {
RewriteMode::Preserve => return Ok(None),
RewriteMode::StripPrefix => {
let tail = path.strip_prefix(&self.path_prefix).unwrap_or("");
if tail.is_empty() {
"/".to_owned()
} else {
tail.to_owned()
}
}
RewriteMode::ReplacePrefix => {
let tail = path.strip_prefix(&self.path_prefix).unwrap_or("");
let base = self.rewrite.value.trim_end_matches('/');
if tail.is_empty() {
if base.is_empty() {
"/".to_owned()
} else {
base.to_owned()
}
} else if base.is_empty() || base == "/" {
tail.to_owned()
} else {
format!("{base}{tail}")
}
}
RewriteMode::Regex => self
.path_regex
.as_ref()
.expect("regex rewrite was validated")
.replace(path, self.rewrite.value.as_str())
.into_owned(),
};
crate::acl::validate_decoded_routing_path(&rewritten)
.context("invalid rewritten location path")?;
Ok(Some(rewritten))
}
}
pub fn prefix_matches(prefix: &str, path: &str) -> bool {
prefix == "/"
|| path == prefix
|| path
.strip_prefix(prefix)
.is_some_and(|tail| tail.starts_with('/'))
}
pub(crate) fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
/// Vhost names are ASCII DNS names (IDNs must be provided as punycode) or IPv4.
pub fn normalize_host(value: &str) -> Result<String> {
let host = value
.strip_suffix('.')
.unwrap_or(value)
.to_ascii_lowercase();
ensure!(
!host.is_empty() && host.len() <= 253,
"hostname length must be 1..253"
);
ensure!(
host.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& !label.starts_with('-')
&& !label.ends_with('-')
&& label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
}),
"invalid hostname: use an ASCII hostname without a port or wildcard"
);
Ok(host)
}
/// Configured frontend patterns may use a single leading `*.` wildcard.
/// Runtime Host values remain exact and are normalized by `normalize_host`.
pub fn normalize_host_pattern(value: &str) -> Result<String> {
let value = value.trim();
if let Some(base) = value.strip_prefix("*.") {
ensure!(
!base.contains('*'),
"wildcard hostname allows only one leading *."
);
let base = normalize_host(base)?;
ensure!(
base.contains('.') && base.parse::<IpAddr>().is_err(),
"wildcard hostname requires a DNS suffix such as *.example.com"
);
return Ok(format!("*.{base}"));
}
ensure!(
!value.contains('*'),
"wildcard hostname allows only one leading *."
);
normalize_host(value)
}
pub fn request_host(value: &str) -> Result<String> {
ensure!(!value.contains('@'), "invalid Host");
let authority: Authority = value.parse().context("invalid Host authority")?;
if let Some(port) = authority.port() {
let number: u16 = port.as_str().parse().context("invalid Host port")?;
ensure!(number > 0, "invalid Host port");
}
let host = authority
.host()
.trim_start_matches('[')
.trim_end_matches(']');
if let Ok(ip) = host.parse::<IpAddr>() {
return Ok(ip.to_string());
}
normalize_host(host)
}
fn ipv4_policy(ip: Ipv4Addr, allow_private: bool) -> Result<()> {
let [a, b, _, _] = ip.octets();
ensure!(
a != 0 && a < 224 && !ip.is_broadcast(),
"unspecified/reserved/multicast target blocked"
);
ensure!(
!(a == 169 && b == 254),
"link-local and metadata targets are blocked"
);
ensure!(
!(a == 100 && (64..=127).contains(&b)),
"shared-address/metadata range is blocked"
);
ensure!(
!(a == 198 && (b == 18 || b == 19)),
"benchmark range is blocked"
);
ensure!(
!ip.is_documentation(),
"documentation address is not a backend"
);
ensure!(
allow_private || (!ip.is_private() && !ip.is_loopback()),
"private/loopback backend requires allow_private=true"
);
Ok(())
}
pub(crate) fn ip_policy(ip: IpAddr, allow_private: bool) -> Result<()> {
match ip {
IpAddr::V4(ip) => ipv4_policy(ip, allow_private),
IpAddr::V6(ip) => {
if let Some(v4) = ip.to_ipv4_mapped() {
return ipv4_policy(v4, allow_private);
}
let s = ip.segments();
ensure!(
!ip.is_unspecified() && !ip.is_multicast(),
"unspecified/multicast target blocked"
);
ensure!(
s[0] & 0xffc0 != 0xfe80,
"IPv6 link-local targets are blocked"
);
ensure!(
!(s[0] == 0x2001 && s[1] == 0x0db8),
"documentation address is not a backend"
);
// Only global unicast, ULA and loopback. Reject IPv4 translation/tunnel special ranges.
let ula = s[0] & 0xfe00 == 0xfc00;
let global =
s[0] & 0xe000 == 0x2000 && s[0] != 0x2002 && !(s[0] == 0x2001 && s[1] == 0);
ensure!(
global || ula || ip.is_loopback(),
"special IPv6 target blocked"
);
ensure!(
allow_private || (!ula && !ip.is_loopback()),
"private/loopback backend requires allow_private=true"
);
Ok(())
}
}
}
pub(crate) fn compile_backend(
backend: &Backend,
policy: &ValidationPolicy,
bindings: &[SocketAddr],
keepalive: &crate::balance::Keepalive,
) -> Result<CompiledBackend> {
compile_backend_with_timeout(
backend,
policy,
bindings,
keepalive,
std::time::Duration::from_secs(10),
)
}