v0.31.8
This commit is contained in:
1 parent
68ada6680d
commit
180a0e4877
56 files changed
+9866
-205
No files matched your search
+905
-3
@@ -1,5 +1,907 @@
|
||||
mod tests {
|
||||
include!("tests/part_01.rs");
|
||||
include!("tests/part_02.rs");
|
||||
include!("tests/part_03.rs");
|
||||
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 logging_vhost_resolution_reuses_router_host_precedence() {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.vhosts[0].id = "wildcard".into();
|
||||
config.vhosts[0].frontend.hosts = vec!["*.example.test".into()];
|
||||
let mut exact = config.vhosts[0].clone();
|
||||
exact.id = "exact".into();
|
||||
exact.frontend.hosts = vec!["api.example.test".into()];
|
||||
config.vhosts.push(exact);
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
|
||||
let routed = snapshot
|
||||
.resolve_logging_vhost(
|
||||
local,
|
||||
false,
|
||||
Some("wildcard"),
|
||||
Some("api.example.test"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(routed.vhost, "wildcard");
|
||||
assert_eq!(routed.matched_by, "router");
|
||||
|
||||
let exact = snapshot
|
||||
.resolve_logging_vhost(local, false, None, Some("API.EXAMPLE.TEST:8080"), None, None)
|
||||
.unwrap();
|
||||
assert_eq!(exact.vhost, "exact");
|
||||
assert_eq!(exact.matched_by, "authority");
|
||||
|
||||
let wildcard = snapshot
|
||||
.resolve_logging_vhost(local, false, None, None, Some("www.example.test:8080"), None)
|
||||
.unwrap();
|
||||
assert_eq!(wildcard.vhost, "wildcard");
|
||||
assert_eq!(wildcard.matched_by, "host");
|
||||
|
||||
let sni = snapshot
|
||||
.resolve_logging_vhost(local, false, None, None, None, Some("api.example.test"))
|
||||
.unwrap();
|
||||
assert_eq!(sni.vhost, "exact");
|
||||
assert_eq!(sni.matched_by, "sni");
|
||||
|
||||
assert!(snapshot
|
||||
.resolve_logging_vhost(local, false, None, None, None, Some("unknown.test"))
|
||||
.is_none());
|
||||
assert!(snapshot
|
||||
.resolve_logging_vhost(local, false, None, None, None, None)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[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 invalid_per_backend_health_policy_rejected_without_global_override() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].backends[0].health_check = Some(crate::balance::HealthCheck {
|
||||
mode: crate::balance::HealthMode::Http,
|
||||
interval_ms: 300,
|
||||
timeout_ms: 400,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(compile(c.clone(), 1, &policy()).is_err());
|
||||
c.vhosts[0].backends[0].health_check.as_mut().unwrap().timeout_ms = 200;
|
||||
assert!(compile(c, 1, &policy()).is_ok());
|
||||
}
|
||||
#[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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn www_redirect_claims_companion_hostname_and_keeps_first_host_canonical() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].frontend.hosts = vec!["example.test".into()];
|
||||
c.vhosts[0].www_redirect = true;
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
assert!(snapshot.select("www.example.test", "/path").is_some());
|
||||
let frontend = snapshot
|
||||
.frontend("www.example.test", "127.0.0.1:8080".parse().unwrap(), false)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
frontend.www_redirect.as_ref(),
|
||||
Some(&("www.example.test".into(), "example.test".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_domains_share_vhost_and_selected_canonical_redirect() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].frontend.hosts = vec![
|
||||
"suba.local".into(), "subb.local".into(), "site.test".into(),
|
||||
];
|
||||
c.vhosts[0].www_redirect = true;
|
||||
c.vhosts[0].www_redirect_target = Some("site.test".into());
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
for hostname in ["suba.local", "subb.local", "site.test", "www.site.test"] {
|
||||
assert!(snapshot.select(hostname, "/same").is_some(), "{hostname}");
|
||||
}
|
||||
for hostname in ["suba.local", "subb.local", "www.site.test"] {
|
||||
let frontend = snapshot.frontend(hostname, "127.0.0.1:8080".parse().unwrap(), false).unwrap();
|
||||
assert_eq!(frontend.www_redirect.as_ref(),
|
||||
Some(&("www.site.test".into(), "site.test".into())));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_www_target_is_claimed_and_other_domain_stays_independent() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].frontend.hosts = vec!["suba.local".into(), "subb.local".into()];
|
||||
c.vhosts[0].www_redirect = true;
|
||||
c.vhosts[0].www_redirect_target = Some("www.subb.local".into());
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
for host in ["suba.local", "subb.local", "www.subb.local"] {
|
||||
assert!(snapshot.select(host, "/path").is_some(), "{host}");
|
||||
}
|
||||
assert!(snapshot.select("www.suba.local", "/path").is_none());
|
||||
let frontend = snapshot.frontend("subb.local", "127.0.0.1:8080".parse().unwrap(), false).unwrap();
|
||||
assert_eq!(frontend.www_redirect.as_ref(),
|
||||
Some(&(String::from("subb.local"), String::from("www.subb.local"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_www_redirect_target_must_belong_to_vhost() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].frontend.hosts = vec!["suba.local".into()];
|
||||
c.vhosts[0].www_redirect = true;
|
||||
c.vhosts[0].www_redirect_target = Some("elsewhere.local".into());
|
||||
assert!(compile(c, 1, &policy()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_health_check_defaults_host_to_first_exact_vhost_dns_name() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].frontend.hosts = vec!["app.example.test".into()];
|
||||
c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Http;
|
||||
c.vhosts[0].load_balancing.health_check.host = None;
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
assert_eq!(
|
||||
snapshot.pools[0].settings.health_check.host.as_deref(),
|
||||
Some("app.example.test")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_member_http_health_inherits_vhost_dns_even_when_global_health_is_tcp() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].frontend.hosts = vec!["app.example.test".into()];
|
||||
assert_eq!(c.vhosts[0].load_balancing.health_check.mode, crate::balance::HealthMode::Tcp);
|
||||
c.vhosts[0].backends[0].health_check = Some(crate::balance::HealthCheck {
|
||||
mode: crate::balance::HealthMode::Http, path: "/healthz".into(), ..Default::default()
|
||||
});
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
let pool = &snapshot.pools[0];
|
||||
assert_eq!(pool.settings.health_for(&pool.members[0].definition).host.as_deref(), Some("app.example.test"));
|
||||
}
|
||||
#[test]
|
||||
fn explicit_http_health_check_host_override_wins_over_vhost_hostname() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].frontend.hosts = vec!["app.example.test".into()];
|
||||
c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Http;
|
||||
c.vhosts[0].load_balancing.health_check.host = Some("health.example.test".into());
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
assert_eq!(
|
||||
snapshot.pools[0].settings.health_check.host.as_deref(),
|
||||
Some("health.example.test")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_location_redirect_supports_separate_capture_regex_and_query() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].routes = vec![crate::vhost::PathRoute {
|
||||
id: "archives".into(),
|
||||
path_prefix: r"\.(?:tar\.gz|zip?)$".into(),
|
||||
match_type: crate::vhost::PathMatch::RegexInsensitive,
|
||||
response: None,
|
||||
redirect: Some(crate::vhost::LocationRedirect {
|
||||
status: 301,
|
||||
target: "https://storage.example.test/o/$1".into(),
|
||||
rewrite_pattern: Some(r"^/(.*)$".into()),
|
||||
preserve_query: true,
|
||||
}),
|
||||
..Default::default()
|
||||
}];
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
let route = snapshot.select("localhost", "/repo/file.tar.gz").unwrap();
|
||||
let uri: http::Uri = "/repo/file.tar.gz?download=1".parse().unwrap();
|
||||
let location = route
|
||||
.redirect_location("/repo/file.tar.gz", &uri)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
location,
|
||||
"https://storage.example.test/o/repo/file.tar.gz?download=1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_php_fpm_profile_is_reused_and_detach_restores_local_configuration() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'ok';").unwrap();
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.php_fpm_profiles.push(crate::web_server::PhpFpmProfile {
|
||||
id: "php-default".into(),
|
||||
name: "Default PHP pool".into(),
|
||||
php: crate::web_server::Php {
|
||||
enabled: true,
|
||||
endpoint: "tcp:127.0.0.1:9000".into(),
|
||||
..Default::default()
|
||||
},
|
||||
});
|
||||
let service = crate::web_server::Service {
|
||||
id: "site-one".into(),
|
||||
name: "Site One".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php_profile_id: Some("php-default".into()),
|
||||
// Standalone settings remain distinct and disabled.
|
||||
php: crate::web_server::Php::default(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut second = service.clone();
|
||||
second.id = "site-two".into();
|
||||
second.name = "Site Two".into();
|
||||
config.web_services.extend([service, second]);
|
||||
config.vhosts[0].source = crate::vhost::Source::WebServer {
|
||||
web_service_id: "site-one".into(),
|
||||
};
|
||||
config.vhosts[0].routes.clear();
|
||||
config.vhosts[0].backends.clear();
|
||||
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
for id in ["site-one", "site-two"] {
|
||||
let compiled = snapshot.web_php_services.get(id).unwrap();
|
||||
assert!(compiled.php.is_some(), "{id} must use the shared FPM profile");
|
||||
}
|
||||
assert_eq!(snapshot.envelope.config.php_fpm_profiles.len(), 1);
|
||||
assert!(!snapshot.envelope.config.web_services[0].php.enabled);
|
||||
assert!(!snapshot.envelope.config.web_services[1].php.enabled);
|
||||
|
||||
let serialized = serde_json::to_string(&snapshot.envelope.config).unwrap();
|
||||
let mut detached: Config = serde_json::from_str(&serialized).unwrap();
|
||||
detached.web_services[0].php_profile_id = None;
|
||||
let snapshot = compile(detached, 2, &policy()).unwrap();
|
||||
assert!(snapshot.web_php_services.get("site-one").unwrap().php.is_none());
|
||||
assert!(snapshot.web_php_services.get("site-two").unwrap().php.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_php_fpm_profile_reference_and_duplicate_ids_are_rejected() {
|
||||
let tmp = tempfile::TempDir::new().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(),
|
||||
php_profile_id: Some("missing".into()),
|
||||
..Default::default()
|
||||
});
|
||||
let err = compile(config.clone(), 1, &policy()).unwrap_err().to_string();
|
||||
assert!(err.contains("unknown PHP-FPM profile"), "{err}");
|
||||
config.web_services[0].php_profile_id = None;
|
||||
let profile = crate::web_server::PhpFpmProfile {
|
||||
id: "duplicate".into(),
|
||||
name: "Duplicate".into(),
|
||||
php: Default::default(),
|
||||
};
|
||||
config.php_fpm_profiles = vec![profile.clone(), profile];
|
||||
let err = compile(config, 1, &policy()).unwrap_err().to_string();
|
||||
assert!(err.contains("duplicate PHP-FPM profile"), "{err}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user