2263 lines
87 KiB
Rust
2263 lines
87 KiB
Rust
//! Desired configuration and its validated, immutable routing snapshot.
|
|
use crate::{
|
|
acl::{Acl, CompiledAcl},
|
|
headers::{CompiledHeaders, HeaderRules},
|
|
};
|
|
use anyhow::{ensure, Context, Result};
|
|
use http::{header::HeaderValue, uri::Authority};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{
|
|
collections::{HashMap, HashSet},
|
|
net::{IpAddr, Ipv4Addr, SocketAddr},
|
|
path::PathBuf,
|
|
sync::Arc,
|
|
};
|
|
use url::{Host, Url};
|
|
|
|
pub const MAX_CONFIG_BYTES: usize = 262_144;
|
|
pub const MAX_BACKENDS: usize = 128;
|
|
pub const MAX_ROUTES: usize = 256;
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Config {
|
|
#[serde(default)]
|
|
pub administration: crate::settings::Administration,
|
|
#[serde(default)]
|
|
pub logging: crate::logging::Settings,
|
|
#[serde(default)]
|
|
pub error_log: crate::logging::ErrorLogSettings,
|
|
#[serde(default)]
|
|
pub prometheus: crate::prometheus::Settings,
|
|
#[serde(default)]
|
|
pub listeners: crate::settings::Listeners,
|
|
/// Legacy/default HTTP/2 lifecycle limits for listeners that do not yet
|
|
/// persist an explicit `binding.http2_settings` object.
|
|
#[serde(default)]
|
|
pub http2: crate::settings::Http2Connections,
|
|
#[serde(default)]
|
|
pub docker_agents: crate::docker::Settings,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub streams: Vec<crate::stream::Host>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub redirects: Vec<crate::redirect::Host>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub not_found_hosts: Vec<crate::not_found::Host>,
|
|
#[serde(default)]
|
|
pub cache: crate::cache::Settings,
|
|
#[serde(default)]
|
|
pub transform: crate::transform::Settings,
|
|
#[serde(default)]
|
|
pub pages: crate::pages::GlobalPages,
|
|
#[serde(default)]
|
|
pub compression: crate::compression::Settings,
|
|
#[serde(default)]
|
|
pub server_header: crate::settings::ServerHeader,
|
|
#[serde(default)]
|
|
pub diagnostic_headers: crate::settings::DiagnosticHeaders,
|
|
#[serde(default)]
|
|
pub forwarding: crate::forwarding::Settings,
|
|
#[serde(default, skip_serializing_if = "crate::tls::Settings::is_empty")]
|
|
pub tls: crate::tls::Settings,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub web_services: Vec<crate::web_server::Service>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub vhosts: Vec<crate::vhost::Vhost>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub host_policies: Vec<HostPolicy>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub acl_profiles: Vec<AclProfile>,
|
|
#[serde(default, skip_serializing_if = "HeaderRules::is_empty")]
|
|
pub headers: HeaderRules,
|
|
}
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct HostPolicy {
|
|
pub hosts: Vec<String>,
|
|
pub acl: Acl,
|
|
}
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default, deny_unknown_fields)]
|
|
pub struct AclProfile {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub acl: Option<Acl>,
|
|
pub country_access: crate::rate_limit::CountryAccess,
|
|
}
|
|
impl Default for AclProfile {
|
|
fn default() -> Self {
|
|
Self {
|
|
id: String::new(),
|
|
name: String::new(),
|
|
acl: None,
|
|
country_access: Default::default(),
|
|
}
|
|
}
|
|
}
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Backend {
|
|
#[serde(default)]
|
|
pub protocol: UpstreamProtocol,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub address_allowlist: Vec<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub tls_ca_pem: Option<String>,
|
|
/// Optional DNS name used for TLS SNI and certificate hostname verification.
|
|
/// TCP/DNS connection targeting remains pinned to `url`.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub tls_server_name: Option<String>,
|
|
/// Verify the HTTPS backend certificate chain and server identity.
|
|
/// Disabled by default for reverse-proxy compatibility; enable explicitly when required.
|
|
#[serde(default)]
|
|
pub tls_verify: bool,
|
|
pub id: String,
|
|
pub url: String,
|
|
#[serde(default)]
|
|
pub allow_private: bool,
|
|
/// Explicitly allow a backend that resolves to a local RustProxy listener.
|
|
/// This can expose the control plane or create a proxy loop and is off by default.
|
|
#[serde(default)]
|
|
pub accept_local_listener_risk: bool,
|
|
#[serde(default)]
|
|
pub preserve_host: bool,
|
|
}
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum UpstreamProtocol {
|
|
#[default]
|
|
Http1,
|
|
Http2,
|
|
Auto,
|
|
}
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Envelope {
|
|
pub revision: u64,
|
|
pub config: Config,
|
|
}
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Update {
|
|
pub expected_revision: u64,
|
|
pub config: Config,
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub struct ValidationPolicy {
|
|
pub tls_addr: Option<SocketAddr>,
|
|
pub cert_dir: Option<PathBuf>,
|
|
/// Resolved runtime data directory. Exposed only through authenticated admin status.
|
|
pub data_dir: Option<PathBuf>,
|
|
pub proxy_addr: SocketAddr,
|
|
pub admin_addr: SocketAddr,
|
|
}
|
|
#[derive(Debug)]
|
|
pub struct CompiledBackend {
|
|
pub address_policy: crate::upstream::AddressPolicy,
|
|
pub client: crate::upstream::ProxyClient,
|
|
pub websocket_client: crate::upstream::ProxyClient,
|
|
pub id: String,
|
|
/// Configured network origin used by raw TCP probes and policy checks.
|
|
pub origin: String,
|
|
/// HTTP request origin. For HTTPS this may carry a separate verified TLS name.
|
|
pub request_origin: String,
|
|
pub host_header: HeaderValue,
|
|
pub preserve_host: bool,
|
|
pub protocol: UpstreamProtocol,
|
|
/// Whether backend TLS certificate chain/name verification is enabled.
|
|
/// Retained in the compiled snapshot for failure diagnostics only.
|
|
pub tls_verify: bool,
|
|
pub accept_local_listener_risk: bool,
|
|
/// True only when this backend URL names the configured administration socket
|
|
/// directly. Used by the guarded control-plane reverse-proxy path.
|
|
pub targets_admin_listener: bool,
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub enum CompiledSource {
|
|
ReverseProxy,
|
|
WebServer(Arc<crate::web_server::CompiledService>),
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct CompiledRoute {
|
|
pub backend_id: Option<String>,
|
|
pub bindings: Vec<crate::vhost::Listener>,
|
|
pub frontend: Option<Arc<CompiledFrontend>>,
|
|
pub pool: Option<Arc<crate::balance::Pool>>,
|
|
pub cache: Option<bool>,
|
|
pub id: String,
|
|
pub path_prefix: String,
|
|
pub match_type: crate::vhost::PathMatch,
|
|
pub path_regex: Option<regex::Regex>,
|
|
pub rewrite: crate::vhost::PathRewrite,
|
|
pub response: Option<crate::vhost::LocationResponse>,
|
|
pub source: CompiledSource,
|
|
pub backend: Option<Arc<CompiledBackend>>,
|
|
pub acl: Option<CompiledAcl>,
|
|
pub headers: CompiledHeaders,
|
|
}
|
|
#[derive(Debug)]
|
|
pub struct CompiledFrontend {
|
|
pub websockets: bool,
|
|
pub websocket_legacy_support: bool,
|
|
pub websocket_rfc8441: bool,
|
|
pub websocket_rfc9220: bool,
|
|
pub force_ssl: bool,
|
|
pub forward_client_ip: bool,
|
|
/// HTTPS target port used by Force SSL redirects. Port 443 wins when multiple TLS ports are selected.
|
|
pub https_redirect_port: Option<u16>,
|
|
pub http2_enabled: bool,
|
|
pub http3_enabled: bool,
|
|
pub tls_policy: crate::tls::ProtocolPolicy,
|
|
pub tls: Option<Arc<rustls::ServerConfig>>,
|
|
pub tls_http1: Option<Arc<rustls::ServerConfig>>,
|
|
pub cloudflare_client_ip: bool,
|
|
pub timing_headers: bool,
|
|
pub backend_header: bool,
|
|
pub backend_timing_header: bool,
|
|
pub rate_limit: crate::rate_limit::Settings,
|
|
pub basic_auth: Option<crate::basic_auth::Compiled>,
|
|
pub metrics: Arc<crate::metrics::VhostMetrics>,
|
|
pub server_header: crate::settings::ServerHeader,
|
|
pub diagnostic_headers: crate::settings::DiagnosticHeaders,
|
|
pub tinyauth: Option<crate::tinyauth::Compiled>,
|
|
pub id: String,
|
|
pub bindings: Vec<crate::vhost::Listener>,
|
|
pub acl_profile_id: Option<String>,
|
|
pub profile_acl: Option<CompiledAcl>,
|
|
pub profile_country_access: crate::rate_limit::CountryAccess,
|
|
pub acl: Option<CompiledAcl>,
|
|
pub headers: CompiledHeaders,
|
|
pub security: crate::security::CompiledSecurity,
|
|
pub cache: crate::cache::Settings,
|
|
pub transform: crate::transform::Settings,
|
|
pub pages: crate::pages::VhostPages,
|
|
pub proxy_timeouts: crate::vhost::ProxyTimeouts,
|
|
}
|
|
pub struct Snapshot {
|
|
pub vhost_metrics: std::collections::BTreeMap<String, Arc<crate::metrics::VhostMetrics>>,
|
|
pub forwarding: crate::forwarding::Compiled,
|
|
pub quic_tls: Option<Arc<quinn::crypto::rustls::QuicServerConfig>>,
|
|
pub listeners: Vec<crate::vhost::Listener>,
|
|
pub pools: Vec<Arc<crate::balance::Pool>>,
|
|
pub tls_http1: Option<Arc<rustls::ServerConfig>>,
|
|
frontends: HashMap<String, Vec<Arc<CompiledFrontend>>>,
|
|
pub tls: Option<Arc<rustls::ServerConfig>>,
|
|
pub envelope: Envelope,
|
|
routes: HashMap<String, Vec<CompiledRoute>>,
|
|
/// Main vhost backend fallback, kept separate from user Custom locations.
|
|
fallbacks: HashMap<String, Vec<CompiledRoute>>,
|
|
redirects: HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>>,
|
|
not_found_hosts: HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>>,
|
|
host_acls: HashMap<String, Arc<CompiledAcl>>,
|
|
pub headers: CompiledHeaders,
|
|
}
|
|
impl std::fmt::Debug for Snapshot {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
// QUIC crypto has no Debug implementation; never format private keys.
|
|
f.debug_struct("Snapshot")
|
|
.field("revision", &self.envelope.revision)
|
|
.field("listeners", &self.listeners)
|
|
.field("quic_tls", &self.quic_tls.is_some())
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
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),
|
|
)
|
|
}
|
|
|
|
pub(crate) fn compile_backend_with_timeout(
|
|
backend: &Backend,
|
|
policy: &ValidationPolicy,
|
|
bindings: &[SocketAddr],
|
|
keepalive: &crate::balance::Keepalive,
|
|
connect_timeout: std::time::Duration,
|
|
) -> Result<CompiledBackend> {
|
|
ensure!(valid_id(&backend.id), "invalid backend id");
|
|
ensure!(backend.url.len() <= 512, "backend URL too long");
|
|
let url = Url::parse(&backend.url).context("invalid backend URL")?;
|
|
ensure!(
|
|
matches!(url.scheme(), "http" | "https"),
|
|
"backend scheme must be http or https"
|
|
);
|
|
ensure!(backend.protocol == UpstreamProtocol::Http1 || !backend.preserve_host,
|
|
"HTTP/2 or auto upstream requires preserve_host=false; use http1 for original Host preservation");
|
|
ensure!(
|
|
backend.tls_ca_pem.is_none() || url.scheme() == "https",
|
|
"backend additional CA can only be used with an https:// URL; remove the CA or change the backend URL to HTTPS"
|
|
);
|
|
ensure!(
|
|
backend.tls_server_name.is_none() || url.scheme() == "https",
|
|
"backend TLS server name can only be used with an https:// URL; remove the TLS server name or change the backend URL to HTTPS"
|
|
);
|
|
let tls_server_name = backend
|
|
.tls_server_name
|
|
.as_deref()
|
|
.map(normalize_host)
|
|
.transpose()?;
|
|
if let Some(name) = &tls_server_name {
|
|
ensure!(
|
|
name.parse::<IpAddr>().is_err(),
|
|
"backend TLS server name must be the DNS name present in the certificate (for example origin.example.internal), not an IP address"
|
|
);
|
|
}
|
|
ensure!(
|
|
url.username().is_empty() && url.password().is_none(),
|
|
"URL credentials are forbidden"
|
|
);
|
|
ensure!(
|
|
url.path() == "/" && url.query().is_none() && url.fragment().is_none(),
|
|
"backend URL must be an origin, without a path, query or fragment"
|
|
);
|
|
let address_policy = crate::upstream::AddressPolicy::new(backend)?
|
|
.with_listeners(policy)
|
|
.with_bindings(bindings);
|
|
match url.host().context("backend host missing")? {
|
|
Host::Ipv4(ip) => address_policy.check(IpAddr::V4(ip))?,
|
|
Host::Ipv6(ip) => address_policy.check(IpAddr::V6(ip))?,
|
|
Host::Domain(domain) => {
|
|
normalize_host(domain)?;
|
|
}
|
|
}
|
|
let port = url
|
|
.port_or_known_default()
|
|
.context("backend port missing")?;
|
|
ensure!(port > 0, "backend port must be nonzero");
|
|
let targets_admin_listener = match url.host().context("backend host missing")? {
|
|
Host::Ipv4(ip) => {
|
|
crate::vhost::socket_overlap(SocketAddr::new(IpAddr::V4(ip), port), policy.admin_addr)
|
|
}
|
|
Host::Ipv6(ip) => {
|
|
crate::vhost::socket_overlap(SocketAddr::new(IpAddr::V6(ip), port), policy.admin_addr)
|
|
}
|
|
// DNS destinations remain subject to the normal self-target guard, but
|
|
// are never trusted for administration-cookie passthrough. The special
|
|
// control-plane path must point at the listener explicitly by IP.
|
|
Host::Domain(_) => false,
|
|
};
|
|
// Exact local listener targets are rejected by AddressPolicy by default, including DNS
|
|
// answers. An explicit per-backend risk acceptance can override only that self-target
|
|
// guard; remote origins on any valid backend port, including 9090, remain valid.
|
|
let authority = match url.host().context("backend host missing")? {
|
|
Host::Ipv6(ip) => format!("[{ip}]:{port}"),
|
|
host => format!("{host}:{port}"),
|
|
};
|
|
let request_authority = tls_server_name
|
|
.as_deref()
|
|
.map(|name| format!("{name}:{port}"))
|
|
.unwrap_or_else(|| authority.clone());
|
|
let client = crate::upstream::client_with_timeout(
|
|
backend,
|
|
address_policy.clone(),
|
|
false,
|
|
keepalive,
|
|
connect_timeout,
|
|
)?;
|
|
let websocket_client = if backend.protocol == UpstreamProtocol::Http1 {
|
|
client.clone()
|
|
} else {
|
|
crate::upstream::client_with_timeout(
|
|
backend,
|
|
address_policy.clone(),
|
|
true,
|
|
keepalive,
|
|
connect_timeout,
|
|
)?
|
|
};
|
|
Ok(CompiledBackend {
|
|
address_policy,
|
|
id: backend.id.clone(),
|
|
origin: format!("{}://{authority}", url.scheme()),
|
|
request_origin: format!("{}://{request_authority}", url.scheme()),
|
|
host_header: authority.parse()?,
|
|
preserve_host: backend.preserve_host,
|
|
protocol: backend.protocol,
|
|
tls_verify: backend.tls_verify,
|
|
accept_local_listener_risk: backend.accept_local_listener_risk,
|
|
targets_admin_listener,
|
|
client,
|
|
websocket_client,
|
|
})
|
|
}
|
|
pub fn compile(config: Config, revision: u64, policy: &ValidationPolicy) -> Result<Snapshot> {
|
|
compile_with_defaults(
|
|
config,
|
|
revision,
|
|
policy,
|
|
(policy.proxy_addr, policy.tls_addr),
|
|
)
|
|
}
|
|
/// Validate both active sockets and the sockets that will be used after restart.
|
|
pub fn compile_with_defaults(
|
|
config: Config,
|
|
revision: u64,
|
|
policy: &ValidationPolicy,
|
|
defaults: (SocketAddr, Option<SocketAddr>),
|
|
) -> Result<Snapshot> {
|
|
compile_reusing(config, revision, policy, defaults, None)
|
|
}
|
|
/// Rebuild clients and route policies, but retain compatible health observations,
|
|
/// full-exchange counters and affinity tokens through unrelated config edits.
|
|
pub fn compile_reusing(
|
|
config: Config,
|
|
revision: u64,
|
|
policy: &ValidationPolicy,
|
|
defaults: (SocketAddr, Option<SocketAddr>),
|
|
previous: Option<&Snapshot>,
|
|
) -> Result<Snapshot> {
|
|
compile_with_active(config, revision, policy, defaults, previous, &[])
|
|
}
|
|
pub fn compile_with_active(
|
|
mut config: Config,
|
|
revision: u64,
|
|
policy: &ValidationPolicy,
|
|
defaults: (SocketAddr, Option<SocketAddr>),
|
|
previous: Option<&Snapshot>,
|
|
active: &[crate::vhost::Listener],
|
|
) -> Result<Snapshot> {
|
|
config.administration.validate(&config.tls)?;
|
|
config.logging.validate()?;
|
|
config.error_log.validate()?;
|
|
let desired_admin = config.administration.resolved_listen(policy.admin_addr);
|
|
// A persisted non-loopback address is only accepted with administration TLS.
|
|
// When `listen` is omitted, the startup/CLI address remains authoritative and
|
|
// its legacy plain-HTTP risk gate is enforced by main before compilation.
|
|
ensure!(
|
|
config.administration.listen.is_none()
|
|
|| desired_admin.ip().is_loopback()
|
|
|| config.administration.tls,
|
|
"a persisted remote administration listener requires administration TLS"
|
|
);
|
|
let mut desired_policy = policy.clone();
|
|
desired_policy.admin_addr = desired_admin;
|
|
config.listeners.validate(&desired_policy)?;
|
|
config.http2.validate()?;
|
|
config.docker_agents.validate(&config.listeners)?;
|
|
let certificate_ids: HashSet<_> = config
|
|
.tls
|
|
.certificates
|
|
.iter()
|
|
.map(|certificate| certificate.id.as_str())
|
|
.collect();
|
|
for agent in &config.docker_agents.agents {
|
|
if let Some(id) = agent.certificate_id.as_deref() {
|
|
ensure!(
|
|
certificate_ids.contains(id),
|
|
"Docker agent {} references unknown certificate id {}",
|
|
agent.id,
|
|
id
|
|
);
|
|
}
|
|
}
|
|
config.prometheus.validate()?;
|
|
config.cache.validate()?;
|
|
config.transform.validate()?;
|
|
config.pages.validate()?;
|
|
config.compression.validate()?;
|
|
config.server_header.validate()?;
|
|
let forwarding = config.forwarding.compile()?;
|
|
ensure!(
|
|
config.web_services.len() <= 128,
|
|
"too many Web Services (max 128)"
|
|
);
|
|
let mut web_services = HashMap::new();
|
|
for service in &mut config.web_services {
|
|
service
|
|
.validate()
|
|
.with_context(|| format!("Web Service {}", service.id))?;
|
|
let compiled = Arc::new(
|
|
service
|
|
.compile()
|
|
.with_context(|| format!("Web Service {}", service.id))?,
|
|
);
|
|
ensure!(
|
|
web_services.insert(service.id.clone(), compiled).is_none(),
|
|
"duplicate Web Service id"
|
|
);
|
|
}
|
|
ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)");
|
|
let mut vhost_ids = HashSet::new();
|
|
for host in &mut config.vhosts {
|
|
host.validate()
|
|
.with_context(|| format!("vhost {}", host.id))?;
|
|
let validate_source = |source: &crate::vhost::Source| -> Result<()> {
|
|
if let crate::vhost::Source::WebServer { web_service_id } = source {
|
|
ensure!(
|
|
web_services.contains_key(web_service_id),
|
|
"vhost {} references unknown Web Service {}",
|
|
host.id,
|
|
web_service_id
|
|
);
|
|
}
|
|
Ok(())
|
|
};
|
|
validate_source(&host.source)?;
|
|
for route in &host.routes {
|
|
if route.response.is_none() {
|
|
validate_source(&route.source)?;
|
|
}
|
|
}
|
|
ensure!(vhost_ids.insert(host.id.clone()), "duplicate vhost id");
|
|
}
|
|
ensure!(
|
|
config.redirects.len() <= 64,
|
|
"too many redirect hosts (max 64)"
|
|
);
|
|
let mut redirect_ids = HashSet::new();
|
|
for redirect in &mut config.redirects {
|
|
redirect
|
|
.validate()
|
|
.with_context(|| format!("redirect {}", redirect.id))?;
|
|
ensure!(
|
|
redirect_ids.insert(redirect.id.clone()),
|
|
"duplicate redirect id"
|
|
);
|
|
}
|
|
ensure!(
|
|
config.not_found_hosts.len() <= 64,
|
|
"too many 404 hosts (max 64)"
|
|
);
|
|
let mut not_found_ids = HashSet::new();
|
|
for host in &mut config.not_found_hosts {
|
|
host.validate()
|
|
.with_context(|| format!("404 host {}", host.id))?;
|
|
ensure!(
|
|
not_found_ids.insert(host.id.clone()),
|
|
"duplicate 404 host id"
|
|
);
|
|
}
|
|
let effective_tls = crate::tls::for_all_frontends(
|
|
&config.tls,
|
|
&config.vhosts,
|
|
&config.redirects,
|
|
&config.not_found_hosts,
|
|
)?;
|
|
let tls = crate::tls::compile(&effective_tls, policy.cert_dir.as_deref())?;
|
|
let tls_http1 = tls
|
|
.as_ref()
|
|
.map(|base| crate::tls::variant(base, &effective_tls.policy, false))
|
|
.transpose()?;
|
|
ensure!(
|
|
config
|
|
.vhosts
|
|
.iter()
|
|
.map(|h| h.backends.len())
|
|
.sum::<usize>()
|
|
<= MAX_BACKENDS,
|
|
"too many backends across all pools (max 128)"
|
|
);
|
|
ensure!(
|
|
config.vhosts.iter().map(|h| h.routes.len()).sum::<usize>() <= MAX_ROUTES,
|
|
"too many paths across all vhosts (max 256)"
|
|
);
|
|
ensure!(
|
|
config
|
|
.redirects
|
|
.iter()
|
|
.map(|h| h.rules.len())
|
|
.sum::<usize>()
|
|
<= MAX_ROUTES,
|
|
"too many rules across all redirect hosts (max 256)"
|
|
);
|
|
ensure!(
|
|
serde_json::to_vec(&config)?.len() <= MAX_CONFIG_BYTES,
|
|
"configuration too large"
|
|
);
|
|
let listeners = crate::vhost::listener_plan(&config, &desired_policy)?;
|
|
crate::stream::validate(
|
|
&mut config.streams,
|
|
&listeners,
|
|
desired_policy.admin_addr,
|
|
config
|
|
.prometheus
|
|
.enabled
|
|
.then_some(config.prometheus.listen),
|
|
)?;
|
|
if config.prometheus.enabled {
|
|
ensure!(
|
|
!crate::vhost::socket_overlap(config.prometheus.listen, desired_policy.admin_addr)
|
|
&& !listeners
|
|
.iter()
|
|
.any(|l| crate::vhost::socket_overlap(config.prometheus.listen, l.address)),
|
|
"metrics: exporter address overlaps a proxy/administration listener"
|
|
);
|
|
}
|
|
let mut guarded_bindings: Vec<_> = listeners.iter().map(|l| l.address).collect();
|
|
if let Some(previous) = previous {
|
|
guarded_bindings.extend(previous.listeners.iter().map(|l| l.address));
|
|
}
|
|
guarded_bindings.extend(active.iter().map(|l| l.address));
|
|
// Startup/default addresses remain protected backend destinations even when
|
|
// the desired named-listener plan changes before a restart.
|
|
guarded_bindings.push(defaults.0);
|
|
guarded_bindings.extend(defaults.1);
|
|
guarded_bindings.extend([
|
|
policy.proxy_addr,
|
|
policy.admin_addr,
|
|
desired_policy.admin_addr,
|
|
]);
|
|
guarded_bindings.extend(policy.tls_addr);
|
|
if config.prometheus.enabled {
|
|
guarded_bindings.push(config.prometheus.listen);
|
|
}
|
|
if let Some(previous) = previous {
|
|
if previous.envelope.config.prometheus.enabled {
|
|
guarded_bindings.push(previous.envelope.config.prometheus.listen);
|
|
}
|
|
}
|
|
guarded_bindings.sort();
|
|
guarded_bindings.dedup();
|
|
let global_headers = config.headers.compile()?;
|
|
ensure!(
|
|
config.host_policies.len() <= 256,
|
|
"too many host ACL policies"
|
|
);
|
|
let mut host_acls = HashMap::new();
|
|
for policy in &mut config.host_policies {
|
|
ensure!(
|
|
!policy.hosts.is_empty(),
|
|
"host policy requires at least one hostname"
|
|
);
|
|
let compiled = Arc::new(policy.acl.compile()?);
|
|
for host in &mut policy.hosts {
|
|
*host = normalize_host_pattern(host)?;
|
|
ensure!(
|
|
host_acls.insert(host.clone(), compiled.clone()).is_none(),
|
|
"duplicate host ACL policy"
|
|
);
|
|
}
|
|
}
|
|
ensure!(config.acl_profiles.len() <= 64, "at most 64 ACL profiles");
|
|
let mut acl_profiles: HashMap<String, (Option<CompiledAcl>, crate::rate_limit::CountryAccess)> =
|
|
HashMap::new();
|
|
for profile in &mut config.acl_profiles {
|
|
ensure!(valid_id(&profile.id), "invalid ACL profile id");
|
|
ensure!(
|
|
!profile.name.trim().is_empty()
|
|
&& profile.name.len() <= 96
|
|
&& !profile.name.chars().any(char::is_control),
|
|
"ACL profile name must be 1..96 bytes without controls"
|
|
);
|
|
profile.name = profile.name.trim().to_owned();
|
|
profile.country_access.validate()?;
|
|
let acl = profile.acl.as_ref().map(Acl::compile).transpose()?;
|
|
ensure!(
|
|
acl_profiles
|
|
.insert(profile.id.clone(), (acl, profile.country_access.clone()))
|
|
.is_none(),
|
|
"duplicate ACL profile id"
|
|
);
|
|
}
|
|
for agent in &config.docker_agents.agents {
|
|
if let Some(profile_id) = agent.acl_profile_id.as_deref() {
|
|
ensure!(
|
|
acl_profiles.contains_key(profile_id),
|
|
"Docker agent {} references unknown default ACL profile {}",
|
|
agent.id,
|
|
profile_id
|
|
);
|
|
}
|
|
for assignment in &agent.assignments {
|
|
if let Some(profile_id) = assignment.acl_profile_id.as_deref() {
|
|
ensure!(
|
|
acl_profiles.contains_key(profile_id),
|
|
"Docker assignment {} on agent {} references unknown ACL profile {}",
|
|
assignment.service_key,
|
|
agent.id,
|
|
profile_id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
let mut routes: HashMap<String, Vec<CompiledRoute>> = HashMap::new();
|
|
let mut fallbacks: HashMap<String, Vec<CompiledRoute>> = HashMap::new();
|
|
let mut frontends: HashMap<String, Vec<Arc<CompiledFrontend>>> = HashMap::new();
|
|
let mut redirects: HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>> = HashMap::new();
|
|
let mut not_found_hosts: HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>> =
|
|
HashMap::new();
|
|
let mut claimed_hosts: HashMap<String, Vec<crate::vhost::Listener>> = HashMap::new();
|
|
for redirect in &config.redirects {
|
|
let bindings = redirect.frontend.resolved(&config.listeners)?;
|
|
// Disabled redirect hosts reserve their names as vhosts do, preventing
|
|
// an overlapping fallback from unexpectedly taking traffic.
|
|
for name in &redirect.frontend.hosts {
|
|
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
|
for a in claimed.iter() {
|
|
for b in &bindings {
|
|
ensure!(
|
|
!crate::vhost::socket_overlap(a.address, b.address),
|
|
"overlapping frontend bindings for hostname {}",
|
|
name
|
|
);
|
|
}
|
|
}
|
|
claimed.extend(bindings.iter().copied());
|
|
}
|
|
if redirect.enabled {
|
|
let compiled = Arc::new(crate::redirect::compile(redirect, &config.listeners)?);
|
|
for name in &redirect.frontend.hosts {
|
|
redirects
|
|
.entry(name.clone())
|
|
.or_default()
|
|
.push(compiled.clone());
|
|
}
|
|
}
|
|
}
|
|
for host in &config.not_found_hosts {
|
|
let bindings = host.frontend.resolved(&config.listeners)?;
|
|
// Disabled local-response hosts reserve names too, avoiding accidental
|
|
// exposure through a broader wildcard vhost.
|
|
for name in &host.frontend.hosts {
|
|
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
|
for a in claimed.iter() {
|
|
for b in &bindings {
|
|
ensure!(
|
|
!crate::vhost::socket_overlap(a.address, b.address),
|
|
"overlapping frontend bindings for hostname {}",
|
|
name
|
|
);
|
|
}
|
|
}
|
|
claimed.extend(bindings.iter().copied());
|
|
}
|
|
if host.enabled {
|
|
let compiled = Arc::new(crate::not_found::compile(host, &config.listeners)?);
|
|
for name in &host.frontend.hosts {
|
|
not_found_hosts
|
|
.entry(name.clone())
|
|
.or_default()
|
|
.push(compiled.clone());
|
|
}
|
|
}
|
|
}
|
|
let mut pools = Vec::new();
|
|
let mut vhost_metrics = std::collections::BTreeMap::new();
|
|
for host in &mut config.vhosts {
|
|
let host_bindings = host.frontend.resolved(&config.listeners)?;
|
|
let tls_ports: HashSet<u16> = host_bindings
|
|
.iter()
|
|
.filter(|binding| binding.tls)
|
|
.map(|binding| binding.address.port())
|
|
.collect();
|
|
if host.websocket_rfc8441 {
|
|
ensure!(
|
|
host_bindings.iter().any(|binding| {
|
|
(binding.tls && binding.http2) || (!binding.tls && binding.h2c)
|
|
}),
|
|
"vhost {} RFC 8441 requires at least one selected HTTP/2-capable listener",
|
|
host.id
|
|
);
|
|
}
|
|
if host.websocket_rfc9220 {
|
|
ensure!(
|
|
host_bindings
|
|
.iter()
|
|
.any(|binding| binding.tls && binding.http3.is_some()),
|
|
"vhost {} RFC 9220 requires at least one selected HTTP/3 listener",
|
|
host.id
|
|
);
|
|
}
|
|
let https_redirect_port = if host.force_ssl {
|
|
ensure!(
|
|
host_bindings.iter().any(|binding| !binding.tls),
|
|
"vhost {} Force SSL requires at least one selected plain HTTP listener",
|
|
host.id
|
|
);
|
|
ensure!(
|
|
!tls_ports.is_empty(),
|
|
"vhost {} Force SSL requires at least one selected TLS listener",
|
|
host.id
|
|
);
|
|
if tls_ports.contains(&443) {
|
|
Some(443)
|
|
} else {
|
|
ensure!(
|
|
tls_ports.len() == 1,
|
|
"vhost {} Force SSL cannot choose an HTTPS target: select one TLS port or include port 443",
|
|
host.id
|
|
);
|
|
tls_ports.iter().copied().next()
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
let cookie = host
|
|
.load_balancing
|
|
.sticky
|
|
.cookie_name
|
|
.as_deref()
|
|
.unwrap_or("");
|
|
ensure!(
|
|
!(cookie.starts_with("__Host-") || cookie.starts_with("__Secure-"))
|
|
|| host_bindings.iter().all(|l| l.tls),
|
|
"prefixed affinity cookies require TLS on every resolved frontend"
|
|
);
|
|
// Disabled vhosts also reserve their name, preventing fallback exposure.
|
|
for name in &host.frontend.hosts {
|
|
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
|
for a in claimed.iter() {
|
|
for b in &host_bindings {
|
|
ensure!(
|
|
!crate::vhost::socket_overlap(a.address, b.address),
|
|
"overlapping frontend bindings for hostname {}",
|
|
name
|
|
);
|
|
}
|
|
}
|
|
claimed.extend(host_bindings.iter().copied());
|
|
}
|
|
let mut compiled_members = Vec::new();
|
|
for definition in &host.backends {
|
|
let mut backend = compile_backend_with_timeout(
|
|
&definition.backend(),
|
|
&desired_policy,
|
|
&guarded_bindings,
|
|
&host.load_balancing.keepalive,
|
|
host.proxy_timeouts
|
|
.connect_timeout()
|
|
.unwrap_or(std::time::Duration::from_secs(10)),
|
|
)
|
|
.with_context(|| format!("vhost {} backend {}", host.id, definition.id))?;
|
|
backend.id = format!("{}/{}", host.id, definition.id);
|
|
compiled_members.push(Arc::new(backend));
|
|
}
|
|
let pool = if compiled_members.is_empty() {
|
|
None
|
|
} else {
|
|
let mut pool = crate::balance::Pool::new(
|
|
host.id.clone(),
|
|
host.load_balancing.clone(),
|
|
&host.backends,
|
|
compiled_members,
|
|
)?;
|
|
if let Some(old) = previous.and_then(|s| s.pools.iter().find(|p| p.id == host.id)) {
|
|
pool.reuse_observations(old);
|
|
}
|
|
Some(Arc::new(pool))
|
|
};
|
|
let metrics = previous
|
|
.and_then(|s| s.vhost_metrics.get(&host.id))
|
|
.cloned()
|
|
.unwrap_or_else(|| Arc::new(crate::metrics::VhostMetrics::default()));
|
|
vhost_metrics.insert(host.id.clone(), metrics.clone());
|
|
let tls_policy = if let Some(policy) = &host.tls_policy {
|
|
policy.validate_narrower_than(&effective_tls.policy)?;
|
|
policy.clone()
|
|
} else {
|
|
effective_tls.policy.clone()
|
|
};
|
|
if host.http3_enabled
|
|
&& host_bindings.iter().any(|binding| binding.http3.is_some())
|
|
&& tls_policy != effective_tls.policy
|
|
{
|
|
anyhow::bail!(
|
|
"vhost {} uses HTTP/3, so its TLS policy must match the global TLS policy; disable HTTP/3 to use a per-vhost TLS override",
|
|
host.id
|
|
);
|
|
}
|
|
let frontend_tls = tls
|
|
.as_ref()
|
|
.map(|base| crate::tls::variant(base, &tls_policy, true))
|
|
.transpose()?;
|
|
let frontend_tls_http1 = tls
|
|
.as_ref()
|
|
.map(|base| crate::tls::variant(base, &tls_policy, false))
|
|
.transpose()?;
|
|
let (profile_acl, profile_country_access) = match host.acl_profile_id.as_deref() {
|
|
Some(id) => acl_profiles.get(id).cloned().ok_or_else(|| {
|
|
anyhow::anyhow!("vhost {} references unknown ACL profile {}", host.id, id)
|
|
})?,
|
|
None => (None, crate::rate_limit::CountryAccess::default()),
|
|
};
|
|
let frontend = Arc::new(CompiledFrontend {
|
|
metrics,
|
|
websockets: host.websockets,
|
|
websocket_legacy_support: host.websocket_legacy_support,
|
|
websocket_rfc8441: host.websocket_rfc8441,
|
|
websocket_rfc9220: host.websocket_rfc9220,
|
|
force_ssl: host.force_ssl,
|
|
forward_client_ip: host.forward_client_ip,
|
|
https_redirect_port,
|
|
http2_enabled: host.http2_enabled,
|
|
http3_enabled: host.http3_enabled,
|
|
tls_policy,
|
|
tls: frontend_tls,
|
|
tls_http1: frontend_tls_http1,
|
|
cloudflare_client_ip: host.cloudflare_client_ip,
|
|
timing_headers: host.timing_headers,
|
|
backend_header: host.backend_header,
|
|
backend_timing_header: host.backend_timing_header,
|
|
rate_limit: host.rate_limit.clone(),
|
|
basic_auth: host.basic_auth.compile()?,
|
|
server_header: host
|
|
.server_header
|
|
.clone()
|
|
.unwrap_or_else(|| config.server_header.clone()),
|
|
diagnostic_headers: host
|
|
.diagnostic_headers
|
|
.clone()
|
|
.unwrap_or_else(|| config.diagnostic_headers.clone()),
|
|
tinyauth: host.tinyauth.compile(&desired_policy, &guarded_bindings)?,
|
|
id: host.id.clone(),
|
|
bindings: host_bindings,
|
|
acl_profile_id: host.acl_profile_id.clone(),
|
|
profile_acl,
|
|
profile_country_access,
|
|
acl: host.acl.as_ref().map(Acl::compile).transpose()?,
|
|
headers: host.headers.compile()?,
|
|
security: host.security_headers.compile()?,
|
|
cache: host.cache.resolve(&config.cache)?,
|
|
transform: host.transform.resolve(&config.transform)?,
|
|
pages: host.pages.clone(),
|
|
proxy_timeouts: host.proxy_timeouts.clone(),
|
|
});
|
|
if host.enabled {
|
|
let (fallback_source, fallback_pool, fallback_backend) = match &host.source {
|
|
crate::vhost::Source::ReverseProxy => {
|
|
let pool = pool.as_ref().ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"vhost {} Reverse Proxy source has no backend pool",
|
|
host.id
|
|
)
|
|
})?;
|
|
(
|
|
CompiledSource::ReverseProxy,
|
|
Some(pool.clone()),
|
|
Some(pool.members[0].backend.clone()),
|
|
)
|
|
}
|
|
crate::vhost::Source::WebServer { web_service_id } => (
|
|
CompiledSource::WebServer(web_services[web_service_id].clone()),
|
|
None,
|
|
None,
|
|
),
|
|
};
|
|
let fallback = CompiledRoute {
|
|
backend_id: None,
|
|
bindings: frontend.bindings.clone(),
|
|
frontend: Some(frontend.clone()),
|
|
pool: fallback_pool,
|
|
cache: None,
|
|
id: format!("{}/main", host.id),
|
|
path_prefix: "/".into(),
|
|
match_type: crate::vhost::PathMatch::Prefix,
|
|
path_regex: None,
|
|
rewrite: crate::vhost::PathRewrite::default(),
|
|
response: None,
|
|
source: fallback_source,
|
|
backend: fallback_backend,
|
|
acl: None,
|
|
headers: crate::headers::CompiledHeaders::default(),
|
|
};
|
|
for name in &host.frontend.hosts {
|
|
fallbacks
|
|
.entry(name.clone())
|
|
.or_default()
|
|
.push(fallback.clone());
|
|
}
|
|
}
|
|
let mut path_ids = HashSet::new();
|
|
let mut path_bindings = HashSet::new();
|
|
for route in &mut host.routes {
|
|
ensure!(
|
|
valid_id(&route.id) && path_ids.insert(route.id.clone()),
|
|
"invalid/duplicate vhost path id"
|
|
);
|
|
let path_regex = match route.match_type {
|
|
crate::vhost::PathMatch::Regex | crate::vhost::PathMatch::RegexInsensitive => {
|
|
ensure!(
|
|
!route.path_prefix.is_empty()
|
|
&& route.path_prefix.len() <= 1024
|
|
&& !route.path_prefix.chars().any(char::is_control),
|
|
"invalid vhost location regex"
|
|
);
|
|
let mut builder = regex::RegexBuilder::new(&route.path_prefix);
|
|
builder
|
|
.case_insensitive(
|
|
route.match_type == crate::vhost::PathMatch::RegexInsensitive,
|
|
)
|
|
.size_limit(2 * 1024 * 1024)
|
|
.dfa_size_limit(2 * 1024 * 1024);
|
|
Some(builder.build().context("invalid vhost location regex")?)
|
|
}
|
|
crate::vhost::PathMatch::Exact => {
|
|
ensure!(
|
|
!route.path_prefix.contains('?')
|
|
&& !route.path_prefix.contains('#')
|
|
&& !route.path_prefix.bytes().any(|b| b <= 32 || b == 127),
|
|
"invalid exact location path"
|
|
);
|
|
route.path_prefix = crate::acl::routing_path(&route.path_prefix)
|
|
.context("invalid exact location path")?;
|
|
None
|
|
}
|
|
crate::vhost::PathMatch::Prefix | crate::vhost::PathMatch::PrefixPriority => {
|
|
normalize_prefix(&mut route.path_prefix)?;
|
|
None
|
|
}
|
|
};
|
|
ensure!(
|
|
path_bindings.insert((route.match_type, route.path_prefix.clone())),
|
|
"duplicate vhost location matcher"
|
|
);
|
|
validate_route_rewrite(route, path_regex.as_ref())?;
|
|
validate_location_response(route.response.as_ref())?;
|
|
let acl = route.acl.as_ref().map(Acl::compile).transpose()?;
|
|
let headers = route.headers.compile()?;
|
|
if route.source.is_reverse_proxy() {
|
|
if let Some(id) = &route.backend_id {
|
|
ensure!(
|
|
host.backends.iter().any(|b| &b.id == id),
|
|
"vhost path references unknown backend {}",
|
|
id
|
|
);
|
|
}
|
|
}
|
|
if host.enabled && route.enabled {
|
|
let (source, route_pool, backend) = if route.response.is_some() {
|
|
// Local return is handled before source dispatch and needs no origin.
|
|
(CompiledSource::ReverseProxy, None, None)
|
|
} else {
|
|
match &route.source {
|
|
crate::vhost::Source::ReverseProxy => {
|
|
let pool = pool.as_ref().ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"vhost {} location {} Reverse Proxy source has no backend pool",
|
|
host.id,
|
|
route.id
|
|
)
|
|
})?;
|
|
let backend = if let Some(id) = route.backend_id.as_deref() {
|
|
pool.members
|
|
.iter()
|
|
.find(|member| member.definition.id == id)
|
|
.map(|member| member.backend.clone())
|
|
.ok_or_else(|| anyhow::anyhow!("unknown backend {id}"))?
|
|
} else {
|
|
pool.members[0].backend.clone()
|
|
};
|
|
(
|
|
CompiledSource::ReverseProxy,
|
|
Some(pool.clone()),
|
|
Some(backend),
|
|
)
|
|
}
|
|
crate::vhost::Source::WebServer { web_service_id } => (
|
|
CompiledSource::WebServer(web_services[web_service_id].clone()),
|
|
None,
|
|
None,
|
|
),
|
|
}
|
|
};
|
|
let compiled = CompiledRoute {
|
|
backend_id: route.backend_id.clone(),
|
|
bindings: frontend.bindings.clone(),
|
|
frontend: Some(frontend.clone()),
|
|
pool: route_pool,
|
|
cache: route.cache,
|
|
id: format!("{}/{}", host.id, route.id),
|
|
path_prefix: route.path_prefix.clone(),
|
|
match_type: route.match_type,
|
|
path_regex,
|
|
rewrite: route.rewrite.clone(),
|
|
response: route.response.clone(),
|
|
source,
|
|
backend,
|
|
acl,
|
|
headers,
|
|
};
|
|
for name in &host.frontend.hosts {
|
|
routes
|
|
.entry(name.clone())
|
|
.or_default()
|
|
.push(compiled.clone());
|
|
}
|
|
}
|
|
}
|
|
if host.enabled {
|
|
for name in &host.frontend.hosts {
|
|
frontends
|
|
.entry(name.clone())
|
|
.or_default()
|
|
.push(frontend.clone());
|
|
}
|
|
if let Some(pool) = pool {
|
|
pools.push(pool);
|
|
}
|
|
}
|
|
}
|
|
let quic_tls = tls
|
|
.as_ref()
|
|
.map(|tls| crate::http3::crypto(tls, &effective_tls.policy))
|
|
.transpose()?;
|
|
ensure!(
|
|
!listeners.iter().any(|l| l.http3.is_some()) || quic_tls.is_some(),
|
|
"HTTP/3 requires available TLS certificates"
|
|
);
|
|
ensure!(
|
|
!listeners.iter().any(|l| l.http3.is_some()) || effective_tls.policy.allows_http3(),
|
|
"HTTP/3 listeners require TLS 1.3 and a TLS 1.3 cipher suite in the global TLS policy"
|
|
);
|
|
Ok(Snapshot {
|
|
vhost_metrics,
|
|
forwarding,
|
|
quic_tls,
|
|
listeners,
|
|
pools,
|
|
tls,
|
|
tls_http1,
|
|
frontends,
|
|
envelope: Envelope { revision, config },
|
|
routes,
|
|
fallbacks,
|
|
redirects,
|
|
not_found_hosts,
|
|
host_acls,
|
|
headers: global_headers,
|
|
})
|
|
}
|
|
fn normalize_prefix(prefix: &mut String) -> Result<()> {
|
|
ensure!(
|
|
prefix.starts_with('/')
|
|
&& prefix.len() <= 512
|
|
&& !prefix.contains('?')
|
|
&& !prefix.contains('#')
|
|
&& !prefix.bytes().any(|b| b <= 32 || b == 127),
|
|
"invalid path prefix"
|
|
);
|
|
if prefix.len() > 1 {
|
|
*prefix = prefix.trim_end_matches('/').to_owned();
|
|
ensure!(!prefix.is_empty(), "invalid path prefix");
|
|
}
|
|
*prefix = crate::acl::routing_path(prefix)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_route_rewrite(
|
|
route: &crate::vhost::PathRoute,
|
|
regex: Option<®ex::Regex>,
|
|
) -> Result<()> {
|
|
use crate::vhost::{PathMatch, RewriteMode};
|
|
ensure!(
|
|
route.rewrite.value.len() <= 2048,
|
|
"location rewrite value is too long"
|
|
);
|
|
ensure!(
|
|
!route.rewrite.value.chars().any(char::is_control),
|
|
"location rewrite contains a control character"
|
|
);
|
|
match route.rewrite.mode {
|
|
RewriteMode::Preserve => ensure!(
|
|
route.rewrite.value.is_empty(),
|
|
"preserve rewrite has no value"
|
|
),
|
|
RewriteMode::StripPrefix => ensure!(
|
|
matches!(
|
|
route.match_type,
|
|
PathMatch::Prefix | PathMatch::PrefixPriority | PathMatch::Exact
|
|
),
|
|
"strip-prefix rewrite requires a prefix/exact location"
|
|
),
|
|
RewriteMode::ReplacePrefix => {
|
|
ensure!(
|
|
matches!(
|
|
route.match_type,
|
|
PathMatch::Prefix | PathMatch::PrefixPriority | PathMatch::Exact
|
|
),
|
|
"replace-prefix rewrite requires a prefix/exact location"
|
|
);
|
|
let mut replacement = route.rewrite.value.clone();
|
|
normalize_prefix(&mut replacement).context("invalid location replacement prefix")?;
|
|
}
|
|
RewriteMode::Regex => ensure!(regex.is_some(), "regex rewrite requires a regex location"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_location_response(response: Option<&crate::vhost::LocationResponse>) -> Result<()> {
|
|
let Some(response) = response else {
|
|
return Ok(());
|
|
};
|
|
ensure!(
|
|
(200..=599).contains(&response.status),
|
|
"location response status must be 200..599"
|
|
);
|
|
ensure!(
|
|
response.body.len() <= 65_536,
|
|
"location response body is too large"
|
|
);
|
|
ensure!(
|
|
!response.content_type.is_empty()
|
|
&& response.content_type.len() <= 256
|
|
&& http::HeaderValue::from_str(&response.content_type).is_ok(),
|
|
"invalid location response content type"
|
|
);
|
|
Ok(())
|
|
}
|
|
pub fn demo_config(addr: SocketAddr) -> Config {
|
|
let mut host: crate::vhost::Vhost = serde_json::from_value(serde_json::json!({
|
|
"id":"demo", "frontend":{"hosts":["localhost","127.0.0.1","demo.localhost"],
|
|
"listener_ids":["http"]},
|
|
"backends":[{"id":"demo", "url":format!("http://{addr}"), "allow_private":true}],
|
|
"load_balancing":{"health_check":{"mode":"off"}}, "routes":[{"id":"root"}]
|
|
}))
|
|
.expect("static vhost demo");
|
|
host.load_balancing.health_check.mode = crate::balance::HealthMode::Off;
|
|
Config {
|
|
listeners: crate::settings::Listeners::from_startup(
|
|
"127.0.0.1:8080".parse().expect("static demo listener"),
|
|
None,
|
|
),
|
|
vhosts: vec![host],
|
|
..Config::default()
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
fn policy() -> ValidationPolicy {
|
|
ValidationPolicy {
|
|
tls_addr: None,
|
|
cert_dir: None,
|
|
data_dir: None,
|
|
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
|
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
|
}
|
|
}
|
|
#[test]
|
|
fn missing_http2_settings_use_release_defaults() {
|
|
let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
|
|
assert_eq!(config.http2, crate::settings::Http2Connections::default());
|
|
config.http2.validate().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn normalized_unicode_prefix_survives_serialization_and_recompile() {
|
|
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
config.vhosts[0].routes[0].path_prefix = "/caf%C3%A9".into();
|
|
let first = compile(config, 0, &policy()).unwrap();
|
|
let saved = serde_json::to_string(&first.envelope.config).unwrap();
|
|
let second = compile(serde_json::from_str(&saved).unwrap(), 1, &policy()).unwrap();
|
|
assert!(second.select("localhost", "/caf\u{e9}/hello").is_some());
|
|
}
|
|
#[test]
|
|
fn web_server_main_source_needs_no_backend_pool() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("index.html"), b"hello").unwrap();
|
|
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
config.web_services.push(crate::web_server::Service {
|
|
id: "site".into(),
|
|
name: "Site".into(),
|
|
document_root: tmp.path().to_path_buf(),
|
|
index: vec!["index.html".into()],
|
|
directory_listing: false,
|
|
..Default::default()
|
|
});
|
|
config.vhosts[0].source = crate::vhost::Source::WebServer {
|
|
web_service_id: "site".into(),
|
|
};
|
|
config.vhosts[0].backends.clear();
|
|
config.vhosts[0].routes.clear();
|
|
let snapshot = compile(config, 1, &policy()).unwrap();
|
|
let route = snapshot.select("localhost", "/").unwrap();
|
|
assert!(matches!(&route.source, CompiledSource::WebServer(_)));
|
|
assert!(route.pool.is_none());
|
|
assert!(route.backend.is_none());
|
|
assert!(snapshot.pools.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn web_server_and_reverse_proxy_can_share_one_vhost() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("index.html"), b"hello").unwrap();
|
|
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
config.web_services.push(crate::web_server::Service {
|
|
id: "site".into(),
|
|
name: "Site".into(),
|
|
document_root: tmp.path().to_path_buf(),
|
|
index: vec!["index.html".into()],
|
|
directory_listing: false,
|
|
..Default::default()
|
|
});
|
|
let host = &mut config.vhosts[0];
|
|
host.source = crate::vhost::Source::WebServer {
|
|
web_service_id: "site".into(),
|
|
};
|
|
host.routes = vec![crate::vhost::PathRoute {
|
|
id: "api".into(),
|
|
path_prefix: "/api".into(),
|
|
source: crate::vhost::Source::ReverseProxy,
|
|
..Default::default()
|
|
}];
|
|
let snapshot = compile(config, 1, &policy()).unwrap();
|
|
let root = snapshot.select("localhost", "/").unwrap();
|
|
let api = snapshot.select("localhost", "/api/test").unwrap();
|
|
assert!(matches!(&root.source, CompiledSource::WebServer(_)));
|
|
assert!(matches!(&api.source, CompiledSource::ReverseProxy));
|
|
assert!(api.pool.is_some());
|
|
assert!(api.backend.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn one_web_service_can_be_reused_by_multiple_vhosts() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
std::fs::write(tmp.path().join("index.html"), b"shared").unwrap();
|
|
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
config.web_services.push(crate::web_server::Service {
|
|
id: "shared-site".into(),
|
|
name: "Shared site".into(),
|
|
document_root: tmp.path().to_path_buf(),
|
|
index: vec!["index.html".into()],
|
|
directory_listing: false,
|
|
..Default::default()
|
|
});
|
|
let first = &mut config.vhosts[0];
|
|
first.source = crate::vhost::Source::WebServer {
|
|
web_service_id: "shared-site".into(),
|
|
};
|
|
first.backends.clear();
|
|
first.routes.clear();
|
|
|
|
let mut second = first.clone();
|
|
second.id = "shared-second".into();
|
|
second.frontend.hosts = vec!["second.localhost".into()];
|
|
config.vhosts.push(second);
|
|
|
|
let snapshot = compile(config, 1, &policy()).unwrap();
|
|
let first_route = snapshot.select("localhost", "/").unwrap();
|
|
let second_route = snapshot.select("second.localhost", "/").unwrap();
|
|
assert!(
|
|
matches!(&first_route.source, CompiledSource::WebServer(service) if service.id == "shared-site")
|
|
);
|
|
assert!(
|
|
matches!(&second_route.source, CompiledSource::WebServer(service) if service.id == "shared-site")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_web_service_reference_is_rejected() {
|
|
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
config.vhosts[0].source = crate::vhost::Source::WebServer {
|
|
web_service_id: "missing".into(),
|
|
};
|
|
let error = compile(config, 1, &policy()).unwrap_err().to_string();
|
|
assert!(error.contains("unknown Web Service"), "{error}");
|
|
}
|
|
|
|
#[test]
|
|
fn missing_source_fields_remain_reverse_proxy_for_old_configs() {
|
|
let source: crate::vhost::Source =
|
|
serde_json::from_value(serde_json::json!({"type":"reverse_proxy"})).unwrap();
|
|
assert_eq!(source, crate::vhost::Source::ReverseProxy);
|
|
let route: crate::vhost::PathRoute =
|
|
serde_json::from_value(serde_json::json!({"id":"legacy"})).unwrap();
|
|
assert_eq!(route.source, crate::vhost::Source::ReverseProxy);
|
|
}
|
|
|
|
#[test]
|
|
fn host_normalization() {
|
|
assert_eq!(request_host("EXAMPLE.com.:8080").unwrap(), "example.com");
|
|
for invalid in [
|
|
"",
|
|
"a..b",
|
|
"*.test",
|
|
"a/b",
|
|
"user@host",
|
|
"-bad.test",
|
|
"a:99999",
|
|
] {
|
|
assert!(request_host(invalid).is_err(), "{invalid}");
|
|
}
|
|
}
|
|
#[test]
|
|
fn wildcard_host_patterns_are_normalized_and_exact_hosts_win() {
|
|
assert_eq!(
|
|
normalize_host_pattern("*.Example.TEST.").unwrap(),
|
|
"*.example.test"
|
|
);
|
|
for invalid in ["*", "foo.*.test", "*.com", "*.*.example.test"] {
|
|
assert!(normalize_host_pattern(invalid).is_err(), "{invalid}");
|
|
}
|
|
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
c.vhosts[0].id = "wildcard".into();
|
|
c.vhosts[0].frontend.hosts = vec!["*.example.test".into()];
|
|
let mut exact = c.vhosts[0].clone();
|
|
exact.id = "exact".into();
|
|
exact.frontend.hosts = vec!["api.example.test".into()];
|
|
c.vhosts.push(exact);
|
|
let snapshot = compile(c, 1, &policy()).unwrap();
|
|
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
|
assert_eq!(
|
|
snapshot
|
|
.select_on("api.example.test", "/", local, false)
|
|
.unwrap()
|
|
.id,
|
|
"exact/main"
|
|
);
|
|
assert_eq!(
|
|
snapshot
|
|
.select_on("www.example.test", "/", local, false)
|
|
.unwrap()
|
|
.id,
|
|
"wildcard/main"
|
|
);
|
|
}
|
|
#[test]
|
|
fn reusable_acl_profile_reference_is_compiled_and_unknown_reference_fails() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
c.acl_profiles = serde_json::from_value(serde_json::json!([{
|
|
"id":"office", "name":"Office",
|
|
"acl":{"default":"deny","rules":[{"network":"10.0.0.0/8","action":"allow"}]},
|
|
"country_access":{"mode":"block","countries":["RU"]}
|
|
}]))
|
|
.unwrap();
|
|
c.vhosts[0].acl_profile_id = Some("office".into());
|
|
let snapshot = compile(c.clone(), 1, &policy()).unwrap();
|
|
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
|
let frontend = snapshot.frontend("localhost", local, false).unwrap();
|
|
assert_eq!(frontend.acl_profile_id.as_deref(), Some("office"));
|
|
assert!(frontend
|
|
.profile_acl
|
|
.as_ref()
|
|
.unwrap()
|
|
.allows("10.2.3.4".parse().unwrap()));
|
|
assert!(!frontend
|
|
.profile_acl
|
|
.as_ref()
|
|
.unwrap()
|
|
.allows("192.0.2.1".parse().unwrap()));
|
|
assert!(!frontend.profile_country_access.allows(Some("RU")));
|
|
|
|
c.vhosts[0].acl_profile_id = Some("missing".into());
|
|
assert!(compile(c, 2, &policy()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn rfc8441_is_advertised_only_on_opted_in_h2_capable_listener() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
c.vhosts[0].websockets = true;
|
|
c.vhosts[0].websocket_rfc8441 = true;
|
|
let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string();
|
|
assert!(error.contains("HTTP/2-capable listener"), "{error}");
|
|
|
|
c.listeners.endpoints[0].binding.h2c = true;
|
|
let snapshot = compile(c.clone(), 2, &policy()).unwrap();
|
|
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
|
assert!(snapshot.websocket_rfc8441_on(local, false));
|
|
assert!(!snapshot.websocket_rfc8441_on(local, true));
|
|
|
|
c.vhosts[0].websocket_rfc8441 = false;
|
|
let snapshot = compile(c, 3, &policy()).unwrap();
|
|
assert!(!snapshot.websocket_rfc8441_on(local, false));
|
|
}
|
|
#[test]
|
|
fn force_ssl_requires_plain_http_and_chooses_https_target() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
c.vhosts[0].force_ssl = true;
|
|
let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string();
|
|
assert!(
|
|
error.contains("at least one selected TLS listener"),
|
|
"{error}"
|
|
);
|
|
|
|
c.listeners.endpoints.push(crate::settings::NamedListener {
|
|
id: "https-a".into(),
|
|
name: "HTTPS A".into(),
|
|
scope: crate::settings::ListenerScope::Internal,
|
|
enabled: true,
|
|
binding: crate::vhost::Listener {
|
|
address: "127.0.0.1:8443".parse().unwrap(),
|
|
tls: true,
|
|
http2: true,
|
|
http2_settings: Some(crate::settings::Http2Connections::default()),
|
|
h2c: false,
|
|
http3: None,
|
|
},
|
|
additional_addresses: vec![],
|
|
});
|
|
c.listeners.endpoints.push(crate::settings::NamedListener {
|
|
id: "https-b".into(),
|
|
name: "HTTPS B".into(),
|
|
scope: crate::settings::ListenerScope::Internal,
|
|
enabled: true,
|
|
binding: crate::vhost::Listener {
|
|
address: "127.0.0.1:9443".parse().unwrap(),
|
|
tls: true,
|
|
http2: true,
|
|
http2_settings: Some(crate::settings::Http2Connections::default()),
|
|
h2c: false,
|
|
http3: None,
|
|
},
|
|
additional_addresses: vec![],
|
|
});
|
|
c.vhosts[0].frontend.listener_ids = vec!["http".into(), "https-a".into(), "https-b".into()];
|
|
let error = compile(c.clone(), 2, &policy()).unwrap_err().to_string();
|
|
assert!(error.contains("cannot choose an HTTPS target"), "{error}");
|
|
|
|
c.listeners
|
|
.endpoints
|
|
.iter_mut()
|
|
.find(|item| item.id == "https-b")
|
|
.unwrap()
|
|
.binding
|
|
.address = "127.0.0.1:443".parse().unwrap();
|
|
let snapshot = compile(c.clone(), 3, &policy()).unwrap();
|
|
let frontend = snapshot
|
|
.frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false)
|
|
.unwrap();
|
|
assert_eq!(frontend.https_redirect_port, Some(443));
|
|
|
|
c.vhosts[0].frontend.listener_ids = vec!["https-a".into(), "https-b".into()];
|
|
let error = compile(c, 4, &policy()).unwrap_err().to_string();
|
|
assert!(error.contains("plain HTTP listener"), "{error}");
|
|
}
|
|
#[test]
|
|
fn backend_tls_server_name_keeps_network_origin_and_changes_verified_request_name() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into();
|
|
c.vhosts[0].backends[0].allow_private = true;
|
|
c.vhosts[0].backends[0].tls_server_name = Some("Backend.Unitra.Internal.".into());
|
|
let s = compile(c, 1, &policy()).unwrap();
|
|
let backend = &s.pools[0].members[0].backend;
|
|
assert_eq!(backend.origin, "https://10.87.20.2:443");
|
|
assert_eq!(
|
|
backend.request_origin,
|
|
"https://backend.unitra.internal:443"
|
|
);
|
|
}
|
|
#[test]
|
|
fn tls_server_name_is_https_dns_only() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
c.vhosts[0].backends[0].tls_server_name = Some("backend.internal".into());
|
|
assert!(compile(c.clone(), 1, &policy()).is_err());
|
|
c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into();
|
|
c.vhosts[0].backends[0].tls_server_name = Some("10.87.20.2".into());
|
|
assert!(compile(c, 1, &policy()).is_err());
|
|
}
|
|
#[test]
|
|
fn backend_tls_verification_defaults_off_and_can_be_enabled_explicitly() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
assert!(!c.vhosts[0].backends[0].tls_verify);
|
|
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
|
|
|
c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into();
|
|
c.vhosts[0].backends[0].allow_private = true;
|
|
c.vhosts[0].backends[0].tls_ca_pem = Some("not a certificate".into());
|
|
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
|
|
|
c.vhosts[0].backends[0].tls_verify = true;
|
|
assert!(compile(c, 1, &policy()).is_err());
|
|
}
|
|
#[test]
|
|
fn redirect_hosts_share_listener_routing_without_opening_vhost_routes() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
c.redirects = serde_json::from_value(serde_json::json!([{
|
|
"id":"legacy","name":"Legacy","frontend":{"hosts":["old.localhost"],"listener_ids":["http"]},
|
|
"rules":[{"id":"root","target":"https://new.localhost/","status_code":308}]
|
|
}])).unwrap();
|
|
let snapshot = compile(c, 1, &policy()).unwrap();
|
|
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
|
let (_host, rule) = snapshot
|
|
.redirect_on("old.localhost", "/a", local, false)
|
|
.unwrap();
|
|
assert_eq!(rule.id, "root");
|
|
assert!(snapshot
|
|
.select_on("old.localhost", "/a", local, false)
|
|
.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn custom_locations_override_but_main_vhost_remains_fallback() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
// The demo document still contains the pre-0.16 synthetic root route.
|
|
// Validation must normalize it away and keep the main backend internally.
|
|
let mut custom = c.vhosts[0].routes[0].clone();
|
|
custom.id = "test".into();
|
|
custom.path_prefix = "/test".into();
|
|
custom.response = Some(crate::vhost::LocationResponse {
|
|
status: 200,
|
|
content_type: "text/plain".into(),
|
|
body: "ok".into(),
|
|
});
|
|
c.vhosts[0].routes.push(custom);
|
|
let snapshot = compile(c, 1, &policy()).unwrap();
|
|
|
|
assert_eq!(
|
|
snapshot.select("localhost", "/test").unwrap().id,
|
|
"demo/test"
|
|
);
|
|
assert_eq!(
|
|
snapshot.select("localhost", "/test/a").unwrap().id,
|
|
"demo/test"
|
|
);
|
|
assert_eq!(snapshot.select("localhost", "/").unwrap().id, "demo/main");
|
|
assert_eq!(
|
|
snapshot.select("localhost", "/abc").unwrap().id,
|
|
"demo/main"
|
|
);
|
|
assert_eq!(
|
|
snapshot.select("localhost", "/static/a.js").unwrap().id,
|
|
"demo/main"
|
|
);
|
|
assert!(snapshot.envelope.config.vhosts[0]
|
|
.routes
|
|
.iter()
|
|
.all(|route| !route.is_legacy_main_fallback()));
|
|
}
|
|
|
|
#[test]
|
|
fn deliberate_root_custom_location_can_override_main_fallback() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
let root = &mut c.vhosts[0].routes[0];
|
|
root.response = Some(crate::vhost::LocationResponse {
|
|
status: 204,
|
|
content_type: "text/plain".into(),
|
|
body: String::new(),
|
|
});
|
|
let snapshot = compile(c, 1, &policy()).unwrap();
|
|
assert_eq!(
|
|
snapshot.select("localhost", "/anything").unwrap().id,
|
|
"demo/root"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn direct_admin_listener_backend_is_marked_for_guarded_control_plane_use() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
c.administration.listen = Some("0.0.0.0:9090".parse().unwrap());
|
|
c.administration.tls = true;
|
|
c.vhosts[0].backends[0].url = "https://127.0.0.1:9090".into();
|
|
c.vhosts[0].backends[0].allow_private = true;
|
|
c.vhosts[0].backends[0].accept_local_listener_risk = true;
|
|
let snapshot = compile(c, 1, &policy()).unwrap();
|
|
assert!(snapshot.pools[0].members[0].backend.targets_admin_listener);
|
|
}
|
|
|
|
#[test]
|
|
fn backend_diagnostic_flags_compile_to_frontend() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
c.vhosts[0].backend_header = true;
|
|
c.vhosts[0].backend_timing_header = true;
|
|
let snapshot = compile(c, 1, &policy()).unwrap();
|
|
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
|
let frontend = snapshot.frontend("localhost", local, false).unwrap();
|
|
assert!(frontend.backend_header);
|
|
assert!(frontend.backend_timing_header);
|
|
}
|
|
|
|
#[test]
|
|
fn path_boundaries() {
|
|
assert!(prefix_matches("/api", "/api"));
|
|
assert!(prefix_matches("/api", "/api/users"));
|
|
assert!(!prefix_matches("/api", "/apix"));
|
|
assert!(prefix_matches("/", "/anything"));
|
|
}
|
|
#[test]
|
|
fn private_requires_opt_in_and_metadata_always_denied() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
|
c.vhosts[0].backends[0].allow_private = false;
|
|
assert!(compile(c.clone(), 1, &policy()).is_err());
|
|
c.vhosts[0].backends[0].allow_private = true;
|
|
for target in [
|
|
"http://169.254.169.254",
|
|
"http://100.100.100.200",
|
|
"http://0.0.0.0",
|
|
"http://127.0.0.1:9090",
|
|
"http://[::ffff:169.254.169.254]",
|
|
"http://[fe80::1]",
|
|
"http://user:pass@127.0.0.1:9000",
|
|
] {
|
|
c.vhosts[0].backends[0].url = target.into();
|
|
assert!(compile(c.clone(), 1, &policy()).is_err(), "{target}");
|
|
}
|
|
c.vhosts[0].backends[0].url = "http://127.0.0.1:9090".into();
|
|
c.vhosts[0].backends[0].accept_local_listener_risk = true;
|
|
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
|
c.vhosts[0].backends[0].allow_private = false;
|
|
assert!(compile(c, 1, &policy()).is_err());
|
|
}
|
|
#[test]
|
|
fn longest_prefix_and_aliases() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
let mut specific = c.vhosts[0].routes[0].clone();
|
|
specific.id = "api".into();
|
|
specific.path_prefix = "/api".into();
|
|
c.vhosts[0].routes.push(specific);
|
|
let s = compile(c, 7, &policy()).unwrap();
|
|
assert_eq!(s.select("localhost", "/api/x").unwrap().id, "demo/api");
|
|
assert_eq!(s.select("demo.localhost", "/apix").unwrap().id, "demo/main");
|
|
assert!(s.select("unknown", "/").is_none());
|
|
assert_eq!(s.envelope.revision, 7);
|
|
}
|
|
#[test]
|
|
fn nginx_style_location_precedence_exact_priority_regex_prefix() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
c.vhosts[0].routes[0].id = "root".into();
|
|
|
|
let mut prefix = c.vhosts[0].routes[0].clone();
|
|
prefix.id = "api-prefix".into();
|
|
prefix.path_prefix = "/api".into();
|
|
|
|
let mut regex = c.vhosts[0].routes[0].clone();
|
|
regex.id = "api-regex".into();
|
|
regex.match_type = crate::vhost::PathMatch::Regex;
|
|
regex.path_prefix = r"^/api/v[0-9]+/".into();
|
|
|
|
let mut exact = c.vhosts[0].routes[0].clone();
|
|
exact.id = "api-exact".into();
|
|
exact.match_type = crate::vhost::PathMatch::Exact;
|
|
exact.path_prefix = "/api/v1/fixed".into();
|
|
|
|
let mut trailing_exact = c.vhosts[0].routes[0].clone();
|
|
trailing_exact.id = "trailing-exact".into();
|
|
trailing_exact.match_type = crate::vhost::PathMatch::Exact;
|
|
trailing_exact.path_prefix = "/trailing/".into();
|
|
|
|
c.vhosts[0]
|
|
.routes
|
|
.extend([prefix.clone(), regex, exact, trailing_exact]);
|
|
let snapshot = compile(c.clone(), 1, &policy()).unwrap();
|
|
assert_eq!(
|
|
snapshot.select("localhost", "/api/v1/fixed").unwrap().id,
|
|
"demo/api-exact"
|
|
);
|
|
assert_eq!(
|
|
snapshot.select("localhost", "/api/v2/users").unwrap().id,
|
|
"demo/api-regex"
|
|
);
|
|
assert_eq!(
|
|
snapshot.select("localhost", "/api/plain").unwrap().id,
|
|
"demo/api-prefix"
|
|
);
|
|
assert_eq!(
|
|
snapshot.select("localhost", "/trailing/").unwrap().id,
|
|
"demo/trailing-exact"
|
|
);
|
|
assert_eq!(
|
|
snapshot.select("localhost", "/trailing").unwrap().id,
|
|
"demo/main"
|
|
);
|
|
|
|
c.vhosts[0].routes.retain(|route| route.id != "api-prefix");
|
|
prefix.match_type = crate::vhost::PathMatch::PrefixPriority;
|
|
prefix.id = "api-priority".into();
|
|
c.vhosts[0].routes.push(prefix);
|
|
let snapshot = compile(c, 2, &policy()).unwrap();
|
|
assert_eq!(
|
|
snapshot.select("localhost", "/api/v2/users").unwrap().id,
|
|
"demo/api-priority"
|
|
);
|
|
}
|
|
#[test]
|
|
fn location_rewrites_are_normalized_and_regex_captures_expand() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
let route = &mut c.vhosts[0].routes[0];
|
|
route.match_type = crate::vhost::PathMatch::Regex;
|
|
route.path_prefix = r"^/api/v([0-9]+)(/.*)$".into();
|
|
route.rewrite = crate::vhost::PathRewrite {
|
|
mode: crate::vhost::RewriteMode::Regex,
|
|
value: "/service/${1}${2}".into(),
|
|
};
|
|
let snapshot = compile(c, 1, &policy()).unwrap();
|
|
let selected = snapshot.select("localhost", "/api/v42/items").unwrap();
|
|
assert_eq!(
|
|
selected
|
|
.rewritten_path("/api/v42/items")
|
|
.unwrap()
|
|
.as_deref(),
|
|
Some("/service/42/items")
|
|
);
|
|
assert_eq!(
|
|
selected
|
|
.rewritten_path("/api/v42/Ubuntu 14.04 LTS")
|
|
.unwrap()
|
|
.as_deref(),
|
|
Some("/service/42/Ubuntu 14.04 LTS")
|
|
);
|
|
}
|
|
#[test]
|
|
fn custom_location_response_is_validated() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
c.vhosts[0].routes[0].response = Some(crate::vhost::LocationResponse {
|
|
status: 200,
|
|
content_type: "application/json".into(),
|
|
body: r#"{"ok":true}"#.into(),
|
|
});
|
|
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
|
c.vhosts[0].routes[0].response.as_mut().unwrap().status = 199;
|
|
assert!(compile(c.clone(), 1, &policy()).is_err());
|
|
c.vhosts[0].routes[0].response.as_mut().unwrap().status = 200;
|
|
c.vhosts[0].routes[0]
|
|
.response
|
|
.as_mut()
|
|
.unwrap()
|
|
.content_type = "text/plain\r\nx-bad: 1".into();
|
|
assert!(compile(c, 1, &policy()).is_err());
|
|
}
|
|
#[test]
|
|
fn local_response_hosts_reserve_frontends_and_do_not_require_backends() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
c.not_found_hosts = serde_json::from_value(serde_json::json!([{
|
|
"id":"missing",
|
|
"name":"Missing site",
|
|
"frontend":{"hosts":["missing.localhost"],"listener_ids":["http"]},
|
|
"page":{"kind":"html","status":404,"html":"<h1>Not found</h1>"}
|
|
}]))
|
|
.unwrap();
|
|
let snapshot = compile(c.clone(), 1, &policy()).unwrap();
|
|
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
|
let host = snapshot
|
|
.not_found_host_on("missing.localhost", local, false)
|
|
.unwrap();
|
|
assert_eq!(host.id, "missing");
|
|
assert_eq!(host.page.status, Some(404));
|
|
|
|
c.not_found_hosts[0].frontend.hosts = vec!["localhost".into()];
|
|
assert!(compile(c, 2, &policy()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn conflicts_are_rejected() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
let mut duplicate = c.vhosts[0].routes[0].clone();
|
|
duplicate.id = "other".into();
|
|
c.vhosts[0].routes.push(duplicate);
|
|
assert!(compile(c, 0, &policy()).is_err());
|
|
}
|
|
#[test]
|
|
fn tls12_only_vhost_override_is_valid_without_http3_listener() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
c.vhosts[0].tls_policy = Some(crate::tls::ProtocolPolicy {
|
|
tls12: true,
|
|
tls13: false,
|
|
cipher_suites: vec![],
|
|
});
|
|
assert!(compile(c, 1, &policy()).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn persisted_remote_administration_requires_tls() {
|
|
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
c.administration.listen = Some("192.0.2.10:9090".parse().unwrap());
|
|
assert!(compile(c.clone(), 1, &policy()).is_err());
|
|
c.administration.tls = true;
|
|
assert!(compile(c, 1, &policy()).is_ok());
|
|
}
|
|
#[test]
|
|
fn legacy_startup_remote_administration_is_left_to_cli_risk_gate() {
|
|
let mut p = policy();
|
|
p.admin_addr = "0.0.0.0:9090".parse().unwrap();
|
|
let c = demo_config("127.0.0.1:9000".parse().unwrap());
|
|
assert!(c.administration.listen.is_none());
|
|
assert!(compile(c, 1, &p).is_ok());
|
|
}
|
|
}
|