use crate::application::Application;
use crate::header::Header;
use crate::proxy_config::{
ActionConfig, DynamicProxy, MatchConfig, ProxyConfig, RouteMatcher,
};
use crate::request::Request;
use crate::server::{Address, ConnectionInfo};
use crate::server_config::ServerConfig;
fn make_conn(ip: &str) -> ConnectionInfo {
ConnectionInfo {
client: Address { ip: ip.to_string(), port: 12345 },
server: Address { ip: "127.0.0.1".to_string(), port: 7878 },
request_size: 16000,
sni_hostname: None,
tls: false,
}
}
fn make_request(method: &str, uri: &str) -> Request {
Request {
method: method.to_string(),
request_uri: uri.to_string(),
http_version: "HTTP/1.1".to_string(),
headers: vec![],
body: Default::default(),
}
}
#[test]
fn route_matcher_exact_path() {
let matcher = RouteMatcher::from_match_config(&MatchConfig {
path: Some("/ping".to_string()),
..Default::default()
});
let conn = make_conn("127.0.0.1");
assert!(matcher.matches(&make_request("GET", "/ping"), &conn));
assert!(!matcher.matches(&make_request("GET", "/ping/extra"), &conn));
assert!(!matcher.matches(&make_request("GET", "/other"), &conn));
}
#[test]
fn route_matcher_path_prefix() {
let matcher = RouteMatcher::from_match_config(&MatchConfig {
path: Some("/api/*".to_string()),
..Default::default()
});
let conn = make_conn("127.0.0.1");
assert!(matcher.matches(&make_request("GET", "/api/users"), &conn));
assert!(matcher.matches(&make_request("POST", "/api/data"), &conn));
assert!(!matcher.matches(&make_request("GET", "/other"), &conn));
}
#[test]
fn route_matcher_method_filter() {
let matcher = RouteMatcher::from_match_config(&MatchConfig {
method: Some("GET".to_string()),
path: Some("/test".to_string()),
..Default::default()
});
let conn = make_conn("127.0.0.1");
assert!(matcher.matches(&make_request("GET", "/test"), &conn));
assert!(!matcher.matches(&make_request("POST", "/test"), &conn));
}
#[test]
fn route_matcher_host() {
let matcher = RouteMatcher::from_match_config(&MatchConfig {
host: Some("example.com".to_string()),
..Default::default()
});
let conn = make_conn("127.0.0.1");
assert!(!matcher.matches(&make_request("GET", "/"), &conn));
let mut req = make_request("GET", "/");
req.headers.push(crate::header::Header {
name: "Host".into(),
value: "example.com".into(),
});
assert!(matcher.matches(&req, &conn));
let mut req2 = make_request("GET", "/");
req2.headers.push(crate::header::Header {
name: "Host".into(),
value: "other.com".into(),
});
assert!(!matcher.matches(&req2, &conn));
}
#[test]
fn route_matcher_no_criteria_matches_all() {
let matcher = RouteMatcher::from_match_config(&MatchConfig::default());
let conn = make_conn("192.168.1.1");
assert!(matcher.matches(&make_request("GET", "/anything"), &conn));
assert!(matcher.matches(&make_request("DELETE", "/whatever"), &conn));
}
#[test]
fn route_matcher_path_with_query_string() {
let matcher = RouteMatcher::from_match_config(&MatchConfig {
path: Some("/search".to_string()),
..Default::default()
});
let conn = make_conn("127.0.0.1");
assert!(matcher.matches(&make_request("GET", "/search?q=rust"), &conn));
}
const FULL_CONFIG: &str = r#"
[[upstream]]
name = "backend"
backends = ["server1:8080", "server2:8080"]
strategy = "round_robin"
[upstream.health_check]
path = "/healthz"
interval_secs = 15
timeout_ms = 3000
healthy_threshold = 2
unhealthy_threshold = 3
[[route]]
name = "api"
[route.match]
path = "/api/*"
method = "GET"
[route.action]
type = "proxy"
[route.action.proxy]
upstream = "backend"
connect_timeout_ms = 3000
read_timeout_ms = 20000
[route.middleware.rate_limit]
max_requests = 500
window_secs = 60
[[route]]
name = "ping"
[route.match]
path = "/ping"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "pong"
content_type = "text/plain"
"#;
#[test]
fn from_str_parses_upstreams() {
let cfg = ProxyConfig::from_str(FULL_CONFIG);
assert_eq!(cfg.upstreams.len(), 1);
assert_eq!(cfg.upstreams[0].name, "backend");
assert_eq!(cfg.upstreams[0].backends, vec!["server1:8080", "server2:8080"]);
assert_eq!(cfg.upstreams[0].strategy, "round_robin");
}
#[test]
fn from_str_parses_health_check() {
let cfg = ProxyConfig::from_str(FULL_CONFIG);
let hc = cfg.upstreams[0].health_check.as_ref().unwrap();
assert_eq!(hc.path, "/healthz");
assert_eq!(hc.interval_secs, 15);
assert_eq!(hc.timeout_ms, 3000);
assert_eq!(hc.healthy_threshold, 2);
assert_eq!(hc.unhealthy_threshold, 3);
}
#[test]
fn from_str_parses_routes() {
let cfg = ProxyConfig::from_str(FULL_CONFIG);
assert_eq!(cfg.routes.len(), 2);
let route0 = &cfg.routes[0];
assert_eq!(route0.name, "api");
assert_eq!(route0.match_.path, Some("/api/*".to_string()));
assert_eq!(route0.match_.method, Some("GET".to_string()));
match &route0.action {
ActionConfig::Proxy { upstream, connect_timeout_ms, .. } => {
assert_eq!(upstream, "backend");
assert_eq!(*connect_timeout_ms, 3000);
}
other => panic!("expected Proxy, got {:?}", other),
}
let route1 = &cfg.routes[1];
assert_eq!(route1.name, "ping");
match &route1.action {
ActionConfig::Respond { status, body, .. } => {
assert_eq!(*status, 200);
assert_eq!(body, "pong");
}
other => panic!("expected Respond, got {:?}", other),
}
}
#[test]
fn from_str_parses_route_middleware() {
let cfg = ProxyConfig::from_str(FULL_CONFIG);
let rl = cfg.routes[0].middleware.rate_limit.as_ref().unwrap();
assert_eq!(rl.max_requests, 500);
assert_eq!(rl.window_secs, 60);
}
#[test]
fn config_driven_app_routes_to_respond_handler() {
let cfg = ProxyConfig::from_str(
r#"
[[route]]
name = "pong"
[route.match]
path = "/ping"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "pong"
content_type = "text/plain"
"#,
);
let (app, _handles) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let req = make_request("GET", "/ping");
let resp = app.execute(&req, &conn).unwrap();
assert_eq!(resp.status_code, 200);
let body: Vec<u8> = resp.content_range_list.into_iter().flat_map(|cr| cr.body).collect();
assert_eq!(body, b"pong");
}
#[test]
fn config_driven_app_falls_back_to_app_for_unmatched() {
let cfg = ProxyConfig::from_str(
r#"
[[route]]
name = "ping"
[route.match]
path = "/ping"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "pong"
content_type = "text/plain"
"#,
);
let (app, _handles) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let req = make_request("GET", "/healthz");
let resp = app.execute(&req, &conn).unwrap();
assert_eq!(resp.status_code, 200);
}
#[test]
fn config_driven_app_with_config_pins_fallback_cors_denial() {
let cfg = ProxyConfig::from_str(
r#"
[[route]]
name = "ping"
[route.match]
path = "/ping"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "pong"
content_type = "text/plain"
"#,
);
let (app, _handles) = crate::proxy_config::builder::build(cfg);
let config = ServerConfig {
cors_allow_all: false,
cors_allow_origins: String::new(), ..ServerConfig::default()
};
let app = app.with_config(config);
let conn = make_conn("127.0.0.1");
let mut req = make_request("GET", "/healthz");
req.headers.push(Header {
name: Header::_ORIGIN.into(),
value: "https://evil.example.com".into(),
});
let resp = app.execute(&req, &conn).unwrap();
assert!(resp._get_header(Header::_ACCESS_CONTROL_ALLOW_ORIGIN.to_string()).is_none());
}
#[test]
fn config_driven_app_redirect() {
let cfg = ProxyConfig::from_str(
r#"
[[route]]
name = "redirect"
[route.match]
path = "/old"
[route.action]
type = "redirect"
[route.action.redirect]
location = "https://example.com/new"
status = 301
"#,
);
let (app, _handles) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let req = make_request("GET", "/old");
let resp = app.execute(&req, &conn).unwrap();
assert_eq!(resp.status_code, 301);
let location = resp
.headers
.iter()
.find(|h| h.name == "Location")
.map(|h| h.value.as_ref())
.unwrap_or("");
assert_eq!(location, "https://example.com/new");
}
#[test]
fn config_driven_app_multiple_routes_first_match_wins() {
let cfg = ProxyConfig::from_str(
r#"
[[route]]
name = "first"
[route.match]
path = "/test"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "first"
content_type = "text/plain"
[[route]]
name = "second"
[route.match]
path = "/test"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "second"
content_type = "text/plain"
"#,
);
let (app, _handles) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let req = make_request("GET", "/test");
let resp = app.execute(&req, &conn).unwrap();
let body: Vec<u8> = resp.content_range_list.into_iter().flat_map(|cr| cr.body).collect();
assert_eq!(body, b"first");
}
#[test]
fn route_matcher_content_type_prefix_matches() {
let matcher = RouteMatcher::from_match_config(&MatchConfig {
content_type: Some("application/grpc*".to_string()),
..Default::default()
});
let conn = make_conn("127.0.0.1");
let mut req = make_request("POST", "/");
req.headers.push(crate::header::Header {
name: "Content-Type".into(),
value: "application/grpc+proto".into(),
});
assert!(matcher.matches(&req, &conn));
let mut req2 = make_request("POST", "/");
req2.headers.push(crate::header::Header {
name: "Content-Type".into(),
value: "application/json".into(),
});
assert!(!matcher.matches(&req2, &conn));
assert!(!matcher.matches(&make_request("POST", "/"), &conn));
}
#[test]
fn route_matcher_sni_hostname() {
let matcher = RouteMatcher::from_match_config(&MatchConfig {
host: Some("api.example.com".to_string()),
..Default::default()
});
let mut conn = make_conn("127.0.0.1");
conn.sni_hostname = Some("api.example.com".to_string());
assert!(matcher.matches(&make_request("GET", "/"), &conn));
conn.sni_hostname = Some("www.example.com".to_string());
assert!(!matcher.matches(&make_request("GET", "/"), &conn));
conn.sni_hostname = None;
assert!(!matcher.matches(&make_request("GET", "/"), &conn));
}
#[test]
fn route_matcher_host_and_path_both_required() {
let matcher = RouteMatcher::from_match_config(&MatchConfig {
host: Some("example.com".to_string()),
path: Some("/api/*".to_string()),
..Default::default()
});
let conn = make_conn("127.0.0.1");
let mut req_right_host = make_request("GET", "/api/users");
req_right_host.headers.push(crate::header::Header {
name: "Host".into(),
value: "example.com".into(),
});
assert!(matcher.matches(&req_right_host, &conn));
let mut req_wrong_host = make_request("GET", "/api/users");
req_wrong_host.headers.push(crate::header::Header {
name: "Host".into(),
value: "other.com".into(),
});
assert!(!matcher.matches(&req_wrong_host, &conn));
let mut req_wrong_path = make_request("GET", "/other");
req_wrong_path.headers.push(crate::header::Header {
name: "Host".into(),
value: "example.com".into(),
});
assert!(!matcher.matches(&req_wrong_path, &conn));
}
#[test]
fn route_matcher_method_case_insensitive() {
let matcher = RouteMatcher::from_match_config(&MatchConfig {
method: Some("POST".to_string()),
..Default::default()
});
let conn = make_conn("127.0.0.1");
assert!(matcher.matches(&make_request("post", "/any"), &conn));
assert!(!matcher.matches(&make_request("get", "/any"), &conn));
}
#[test]
fn from_str_parses_tcp_proxy() {
let cfg = ProxyConfig::from_str(r#"
[[tcp_proxy]]
name = "pg"
listen = "0.0.0.0:5432"
backends = ["db1:5432", "db2:5432"]
connect_timeout_ms = 1000
"#);
assert_eq!(cfg.tcp_proxies.len(), 1);
let tcp = &cfg.tcp_proxies[0];
assert_eq!(tcp.name, "pg");
assert_eq!(tcp.listen, "0.0.0.0:5432");
assert_eq!(tcp.backends, vec!["db1:5432", "db2:5432"]);
assert_eq!(tcp.connect_timeout_ms, 1000);
}
#[test]
fn from_str_parses_udp_proxy() {
let cfg = ProxyConfig::from_str(r#"
[[udp_proxy]]
name = "dns"
listen = "0.0.0.0:53"
backends = ["8.8.8.8:53"]
reply_timeout_ms = 2000
buffer_size = 4096
"#);
assert_eq!(cfg.udp_proxies.len(), 1);
let udp = &cfg.udp_proxies[0];
assert_eq!(udp.name, "dns");
assert_eq!(udp.listen, "0.0.0.0:53");
assert_eq!(udp.backends, vec!["8.8.8.8:53"]);
assert_eq!(udp.reply_timeout_ms, 2000);
assert_eq!(udp.buffer_size, 4096);
}
#[test]
fn from_str_parses_ws_proxy() {
let cfg = ProxyConfig::from_str(r#"
[[ws_proxy]]
name = "chat"
listen = "0.0.0.0:9000"
backends = ["ws1:8080", "ws2:8080"]
connect_timeout_ms = 500
read_timeout_ms = 30000
"#);
assert_eq!(cfg.ws_proxies.len(), 1);
let ws = &cfg.ws_proxies[0];
assert_eq!(ws.name, "chat");
assert_eq!(ws.backends, vec!["ws1:8080", "ws2:8080"]);
assert_eq!(ws.connect_timeout_ms, 500);
assert_eq!(ws.read_timeout_ms, 30000);
assert!(ws.health_check.is_none(), "no [ws_proxy.health_check] section was given");
}
#[test]
fn from_str_parses_ws_proxy_health_check() {
let cfg = ProxyConfig::from_str(r#"
[[ws_proxy]]
name = "chat"
listen = "0.0.0.0:9000"
backends = ["wss://ws1:8443", "wss://ws2:8443"]
[ws_proxy.health_check]
path = "/healthz"
interval_secs = 5
timeout_ms = 1000
healthy_threshold = 2
unhealthy_threshold = 3
"#);
assert_eq!(cfg.ws_proxies.len(), 1);
let ws = &cfg.ws_proxies[0];
assert_eq!(ws.backends, vec!["wss://ws1:8443", "wss://ws2:8443"]);
let hc = ws.health_check.as_ref().expect("[ws_proxy.health_check] should be parsed");
assert_eq!(hc.path, "/healthz");
assert_eq!(hc.interval_secs, 5);
assert_eq!(hc.timeout_ms, 1000);
assert_eq!(hc.healthy_threshold, 2);
assert_eq!(hc.unhealthy_threshold, 3);
}
#[test]
fn from_str_ws_proxy_health_check_defaults() {
let cfg = ProxyConfig::from_str(r#"
[[ws_proxy]]
name = "chat"
listen = "0.0.0.0:9000"
backends = ["ws1:8080"]
[ws_proxy.health_check]
"#);
let hc = cfg.ws_proxies[0].health_check.as_ref().unwrap();
assert_eq!(hc.path, "/health");
assert_eq!(hc.interval_secs, 30);
assert_eq!(hc.timeout_ms, 5000);
assert_eq!(hc.healthy_threshold, 2);
assert_eq!(hc.unhealthy_threshold, 3);
}
#[test]
fn from_str_parses_cache_middleware() {
let cfg = ProxyConfig::from_str(r#"
[[route]]
name = "cached"
[route.match]
path = "/static/*"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "ok"
content_type = "text/plain"
[route.middleware.cache]
ttl_secs = 3600
vary_by = ["Accept-Encoding", "Accept-Language"]
"#);
let cache = cfg.routes[0].middleware.cache.as_ref().unwrap();
assert_eq!(cache.ttl_secs, 3600);
assert_eq!(cache.vary_by, vec!["Accept-Encoding", "Accept-Language"]);
}
#[test]
fn from_str_parses_bearer_auth() {
let cfg = ProxyConfig::from_str(r#"
[[route]]
name = "protected"
[route.match]
path = "/secret"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "secret data"
content_type = "text/plain"
[route.middleware.auth]
type = "bearer"
token_env = "MY_API_TOKEN"
"#);
match cfg.routes[0].middleware.auth.as_ref().unwrap() {
crate::proxy_config::AuthConfig::Bearer { token_env } => {
assert_eq!(token_env, "MY_API_TOKEN");
}
other => panic!("expected Bearer, got {:?}", other),
}
}
#[test]
fn from_str_parses_jwt_auth() {
let cfg = ProxyConfig::from_str(r#"
[[route]]
name = "protected"
[route.match]
path = "/secret"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "secret data"
content_type = "text/plain"
[route.middleware.auth]
type = "jwt"
secret_env = "JWT_SECRET"
"#);
match cfg.routes[0].middleware.auth.as_ref().unwrap() {
crate::proxy_config::AuthConfig::Jwt { secret_env } => {
assert_eq!(secret_env, "JWT_SECRET");
}
other => panic!("expected Jwt, got {:?}", other),
}
}
#[test]
fn from_str_parses_basic_auth() {
let cfg = ProxyConfig::from_str(r#"
[[route]]
name = "protected"
[route.match]
path = "/secret"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "secret data"
content_type = "text/plain"
[route.middleware.auth]
type = "basic"
htpasswd_file = ".htpasswd"
"#);
match cfg.routes[0].middleware.auth.as_ref().unwrap() {
crate::proxy_config::AuthConfig::Basic { htpasswd_file } => {
assert_eq!(htpasswd_file, ".htpasswd");
}
other => panic!("expected Basic, got {:?}", other),
}
}
#[test]
fn from_str_parses_rewrite_request_rules() {
let cfg = ProxyConfig::from_str(r#"
[[route]]
name = "rewrite"
[route.match]
path = "/api/*"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "ok"
content_type = "text/plain"
[[route.middleware.rewrite.request]]
type = "header_set"
name = "X-Forwarded-Host"
value = "api.example.com"
[[route.middleware.rewrite.request]]
type = "uri_strip_prefix"
prefix = "/api"
"#);
let rules = &cfg.routes[0].middleware.rewrite_request;
assert_eq!(rules.len(), 2);
assert_eq!(rules[0].type_, "header_set");
assert_eq!(rules[0].name.as_deref(), Some("X-Forwarded-Host"));
assert_eq!(rules[0].value.as_deref(), Some("api.example.com"));
assert_eq!(rules[1].type_, "uri_strip_prefix");
assert_eq!(rules[1].prefix.as_deref(), Some("/api"));
}
#[test]
fn from_str_parses_rewrite_response_rules() {
let cfg = ProxyConfig::from_str(r#"
[[route]]
name = "secure"
[route.match]
path = "/*"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "ok"
content_type = "text/plain"
[[route.middleware.rewrite.response]]
type = "header_set"
name = "X-Frame-Options"
value = "DENY"
[[route.middleware.rewrite.response]]
type = "header_remove"
name = "Server"
"#);
let rules = &cfg.routes[0].middleware.rewrite_response;
assert_eq!(rules.len(), 2);
assert_eq!(rules[0].type_, "header_set");
assert_eq!(rules[0].name.as_deref(), Some("X-Frame-Options"));
assert_eq!(rules[0].value.as_deref(), Some("DENY"));
assert_eq!(rules[1].type_, "header_remove");
assert_eq!(rules[1].name.as_deref(), Some("Server"));
}
#[test]
fn from_str_parses_ip_filter() {
let cfg = ProxyConfig::from_str(r#"
[[route]]
name = "internal"
[route.match]
path = "/admin/*"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "admin"
content_type = "text/plain"
[route.middleware.ip_filter]
allow = ["10.0.0.0/8", "192.168.1.1"]
"#);
let mw = &cfg.routes[0].middleware;
assert_eq!(mw.ip_allow, vec!["10.0.0.0/8", "192.168.1.1"]);
assert!(mw.ip_deny.is_empty());
}
#[test]
fn from_str_parses_redirect_302() {
let cfg = ProxyConfig::from_str(r#"
[[route]]
name = "temp-redirect"
[route.match]
path = "/moved"
[route.action]
type = "redirect"
[route.action.redirect]
location = "https://example.com/new"
status = 302
"#);
match &cfg.routes[0].action {
ActionConfig::Redirect { location, status } => {
assert_eq!(location, "https://example.com/new");
assert_eq!(*status, 302);
}
other => panic!("expected Redirect, got {:?}", other),
}
}
#[test]
fn respond_action_sets_content_type_header() {
let cfg = ProxyConfig::from_str(r#"
[[route]]
name = "json-resp"
[route.match]
path = "/data"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "{\"ok\":true}"
content_type = "application/json"
"#);
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let resp = app.execute(&make_request("GET", "/data"), &conn).unwrap();
assert_eq!(resp.status_code, 200);
let ct = &resp.content_range_list[0].content_type;
assert!(ct.contains("application/json"), "expected application/json, got {ct}");
}
#[test]
fn redirect_action_substitutes_dollar_path() {
let cfg = ProxyConfig::from_str(r#"
[[route]]
name = "path-redirect"
[route.match]
path = "/*"
[route.action]
type = "redirect"
[route.action.redirect]
location = "https://new.example.com$path"
status = 301
"#);
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let resp = app.execute(&make_request("GET", "/about"), &conn).unwrap();
assert_eq!(resp.status_code, 301);
let location = resp.headers.iter().find(|h| h.name == "Location").map(|h| h.value.as_ref()).unwrap_or("");
assert_eq!(location, "https://new.example.com/about");
}
#[test]
fn rate_limit_middleware_returns_429_after_limit() {
let cfg = ProxyConfig::from_str(r#"
[[route]]
name = "limited"
[route.match]
path = "/limited"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "ok"
content_type = "text/plain"
[route.middleware.rate_limit]
max_requests = 1
window_secs = 60
"#);
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("10.77.77.77");
let req = make_request("GET", "/limited");
let r1 = app.execute(&req, &conn).unwrap();
assert_eq!(r1.status_code, 200, "first request should pass");
let r2 = app.execute(&req, &conn).unwrap();
assert_eq!(r2.status_code, 429, "second request should be rate-limited");
let retry = r2
.headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case("Retry-After"))
.unwrap_or_else(|| panic!("no Retry-After on the 429: {:?}", r2.headers));
let seconds: u64 = retry.value.parse().expect("Retry-After must be an integer");
assert!(seconds > 0 && seconds <= 60, "Retry-After out of range: {seconds}");
}
#[test]
fn bearer_auth_returns_401_without_authorization_header() {
std::env::set_var("RWSTEST_PROXY_BEARER_A", "supersecret");
let cfg = ProxyConfig::from_str(r#"
[[route]]
name = "protected"
[route.match]
path = "/secret"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "secret"
content_type = "text/plain"
[route.middleware.auth]
type = "bearer"
token_env = "RWSTEST_PROXY_BEARER_A"
"#);
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let resp = app.execute(&make_request("GET", "/secret"), &conn).unwrap();
assert_eq!(resp.status_code, 401);
}
#[test]
fn bearer_auth_returns_401_with_wrong_token() {
std::env::set_var("RWSTEST_PROXY_BEARER_B", "correct-token");
let cfg = ProxyConfig::from_str(r#"
[[route]]
name = "protected"
[route.match]
path = "/secret"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "secret"
content_type = "text/plain"
[route.middleware.auth]
type = "bearer"
token_env = "RWSTEST_PROXY_BEARER_B"
"#);
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let mut req = make_request("GET", "/secret");
req.headers.push(crate::header::Header {
name: "Authorization".into(),
value: "Bearer wrong-token".into(),
});
let resp = app.execute(&req, &conn).unwrap();
assert_eq!(resp.status_code, 401);
}
#[test]
fn bearer_auth_passes_with_correct_token() {
std::env::set_var("RWSTEST_PROXY_BEARER_C", "my-valid-token");
let cfg = ProxyConfig::from_str(r#"
[[route]]
name = "protected"
[route.match]
path = "/secret"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "secret"
content_type = "text/plain"
[route.middleware.auth]
type = "bearer"
token_env = "RWSTEST_PROXY_BEARER_C"
"#);
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let mut req = make_request("GET", "/secret");
req.headers.push(crate::header::Header {
name: "Authorization".into(),
value: "Bearer my-valid-token".into(),
});
let resp = app.execute(&req, &conn).unwrap();
assert_eq!(resp.status_code, 200);
}
#[cfg(feature = "auth")]
mod auth_feature_tests {
use super::*;
fn jwt_route_config(secret_env: &str) -> String {
format!(
r#"
[[route]]
name = "protected"
[route.match]
path = "/secret"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "secret"
content_type = "text/plain"
[route.middleware.auth]
type = "jwt"
secret_env = "{secret_env}"
"#
)
}
#[test]
fn jwt_auth_rejects_missing_token() {
std::env::set_var("RWSTEST_PROXY_JWT_A", "my-signing-secret");
let cfg = ProxyConfig::from_str(&jwt_route_config("RWSTEST_PROXY_JWT_A"));
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let resp = app.execute(&make_request("GET", "/secret"), &conn).unwrap();
assert_eq!(401, resp.status_code);
}
#[test]
fn jwt_auth_rejects_invalid_token() {
std::env::set_var("RWSTEST_PROXY_JWT_B", "my-signing-secret");
let cfg = ProxyConfig::from_str(&jwt_route_config("RWSTEST_PROXY_JWT_B"));
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let mut req = make_request("GET", "/secret");
req.headers.push(crate::header::Header {
name: "Authorization".into(),
value: "Bearer not-a-valid-jwt".into(),
});
let resp = app.execute(&req, &conn).unwrap();
assert_eq!(401, resp.status_code);
}
#[test]
fn jwt_auth_passes_with_valid_token() {
std::env::set_var("RWSTEST_PROXY_JWT_C", "my-signing-secret");
let cfg = ProxyConfig::from_str(&jwt_route_config("RWSTEST_PROXY_JWT_C"));
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let token = crate::auth::build_jwt(r#"{"sub":"u1"}"#, b"my-signing-secret");
let mut req = make_request("GET", "/secret");
req.headers.push(crate::header::Header {
name: "Authorization".into(),
value: format!("Bearer {token}").into(),
});
let resp = app.execute(&req, &conn).unwrap();
assert_eq!(200, resp.status_code);
}
#[test]
fn jwt_auth_fails_closed_when_secret_env_is_unset() {
std::env::remove_var("RWSTEST_PROXY_JWT_UNSET");
let cfg = ProxyConfig::from_str(&jwt_route_config("RWSTEST_PROXY_JWT_UNSET"));
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let resp = app.execute(&make_request("GET", "/secret"), &conn).unwrap();
assert_eq!(503, resp.status_code);
}
fn basic_route_config(htpasswd_file: &str) -> String {
format!(
r#"
[[route]]
name = "protected"
[route.match]
path = "/secret"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "secret"
content_type = "text/plain"
[route.middleware.auth]
type = "basic"
htpasswd_file = "{htpasswd_file}"
"#
)
}
fn temp_htpasswd(contents: &str, suffix: &str) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!("rws_proxy_htpasswd_{}_{}", std::process::id(), suffix));
std::fs::write(&path, contents).unwrap();
path
}
#[test]
fn basic_auth_passes_with_correct_credentials() {
let path = temp_htpasswd("alice:s3cret\n", "ok");
let cfg = ProxyConfig::from_str(&basic_route_config(path.to_str().unwrap()));
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let mut req = make_request("GET", "/secret");
req.headers.push(crate::header::Header {
name: "Authorization".into(),
value: format!("Basic {}", crate::auth::base64_encode(b"alice:s3cret")).into(),
});
let resp = app.execute(&req, &conn).unwrap();
assert_eq!(200, resp.status_code);
std::fs::remove_file(&path).ok();
}
#[test]
fn basic_auth_rejects_wrong_password() {
let path = temp_htpasswd("alice:s3cret\n", "wrong");
let cfg = ProxyConfig::from_str(&basic_route_config(path.to_str().unwrap()));
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let mut req = make_request("GET", "/secret");
req.headers.push(crate::header::Header {
name: "Authorization".into(),
value: format!("Basic {}", crate::auth::base64_encode(b"alice:wrongpass")).into(),
});
let resp = app.execute(&req, &conn).unwrap();
assert_eq!(401, resp.status_code);
std::fs::remove_file(&path).ok();
}
#[test]
fn basic_auth_rejects_missing_authorization_header() {
let path = temp_htpasswd("alice:s3cret\n", "missing");
let cfg = ProxyConfig::from_str(&basic_route_config(path.to_str().unwrap()));
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let resp = app.execute(&make_request("GET", "/secret"), &conn).unwrap();
assert_eq!(401, resp.status_code);
std::fs::remove_file(&path).ok();
}
#[test]
fn basic_auth_fails_closed_when_htpasswd_file_is_missing() {
let cfg = ProxyConfig::from_str(&basic_route_config("/nonexistent/.htpasswd-rws-test"));
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let resp = app.execute(&make_request("GET", "/secret"), &conn).unwrap();
assert_eq!(503, resp.status_code);
}
}
#[test]
fn ip_allow_filter_blocks_ip_not_in_allowlist() {
let cfg = ProxyConfig::from_str(r#"
[[route]]
name = "internal"
[route.match]
path = "/admin"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "admin"
content_type = "text/plain"
[route.middleware.ip_filter]
allow = ["192.168.1.0/24"]
"#);
let (app, _) = crate::proxy_config::builder::build(cfg);
let outside = make_conn("10.0.0.1");
let resp = app.execute(&make_request("GET", "/admin"), &outside).unwrap();
assert_eq!(resp.status_code, 403);
let inside = make_conn("192.168.1.55");
let resp2 = app.execute(&make_request("GET", "/admin"), &inside).unwrap();
assert_eq!(resp2.status_code, 200);
}
#[test]
fn ip_deny_filter_blocks_listed_ip() {
let cfg = ProxyConfig::from_str(r#"
[[route]]
name = "open-but-blocked"
[route.match]
path = "/page"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "hello"
content_type = "text/plain"
[route.middleware.ip_filter]
deny = ["10.0.0.5"]
"#);
let (app, _) = crate::proxy_config::builder::build(cfg);
let blocked = make_conn("10.0.0.5");
let resp = app.execute(&make_request("GET", "/page"), &blocked).unwrap();
assert_eq!(resp.status_code, 403);
let allowed = make_conn("10.0.0.6");
let resp2 = app.execute(&make_request("GET", "/page"), &allowed).unwrap();
assert_eq!(resp2.status_code, 200);
}
#[test]
fn rewrite_response_header_injected() {
let cfg = ProxyConfig::from_str(r#"
[[route]]
name = "secure-headers"
[route.match]
path = "/page"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "hello"
content_type = "text/plain"
[[route.middleware.rewrite.response]]
type = "header_set"
name = "X-Frame-Options"
value = "DENY"
"#);
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let resp = app.execute(&make_request("GET", "/page"), &conn).unwrap();
assert_eq!(resp.status_code, 200);
let xfo = resp.headers.iter().find(|h| h.name.eq_ignore_ascii_case("X-Frame-Options")).map(|h| h.value.as_ref()).unwrap_or("");
assert_eq!(xfo, "DENY");
}
#[test]
fn static_action_serves_file_from_configured_root() {
let dir = std::env::temp_dir().join(format!("rws_static_test_file_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("hello.txt"), b"hello from static root").unwrap();
let cfg = ProxyConfig::from_str(&format!(
r#"
[[route]]
name = "static-site"
[route.match]
path = "/*"
[route.action]
type = "static"
[route.action.static]
root = "{}"
"#,
dir.to_str().unwrap()
));
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let resp = app.execute(&make_request("GET", "/hello.txt"), &conn).unwrap();
assert_eq!(resp.status_code, 200);
assert_eq!(resp.content_range_list[0].body, b"hello from static root");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn static_action_serves_index_for_directory_request() {
let dir = std::env::temp_dir().join(format!("rws_static_test_idx_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("index.html"), b"<h1>home</h1>").unwrap();
let cfg = ProxyConfig::from_str(&format!(
r#"
[[route]]
name = "static-site"
[route.match]
path = "/*"
[route.action]
type = "static"
[route.action.static]
root = "{}"
"#,
dir.to_str().unwrap()
));
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let resp = app.execute(&make_request("GET", "/"), &conn).unwrap();
assert_eq!(resp.status_code, 200);
assert_eq!(resp.content_range_list[0].body, b"<h1>home</h1>");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn static_action_rejects_path_traversal() {
let dir = std::env::temp_dir().join(format!("rws_static_test_trav_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("secret.txt"), b"root-only").unwrap();
let outside = std::env::temp_dir().join(format!("rws_static_outside_{}.txt", std::process::id()));
std::fs::write(&outside, b"outside secret").unwrap();
let cfg = ProxyConfig::from_str(&format!(
r#"
[[route]]
name = "static-site"
[route.match]
path = "/*"
[route.action]
type = "static"
[route.action.static]
root = "{}"
"#,
dir.to_str().unwrap()
));
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let traversal_uri = format!("/../{}", outside.file_name().unwrap().to_str().unwrap());
let resp = app.execute(&make_request("GET", &traversal_uri), &conn).unwrap();
assert!(resp.status_code == 403 || resp.status_code == 404, "{}", resp.status_code);
let body: Vec<u8> = resp.content_range_list.into_iter().flat_map(|cr| cr.body).collect();
assert!(!String::from_utf8_lossy(&body).contains("outside secret"));
std::fs::remove_dir_all(&dir).ok();
std::fs::remove_file(&outside).ok();
}
#[test]
fn static_action_missing_file_is_404() {
let dir = std::env::temp_dir().join(format!("rws_static_test_404_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg = ProxyConfig::from_str(&format!(
r#"
[[route]]
name = "static-site"
[route.match]
path = "/*"
[route.action]
type = "static"
[route.action.static]
root = "{}"
"#,
dir.to_str().unwrap()
));
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let resp = app.execute(&make_request("GET", "/nope.txt"), &conn).unwrap();
assert_eq!(resp.status_code, 404);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn is_proxy_mode_false_when_no_config_file() {
let _ = ProxyConfig::is_proxy_mode();
}
fn two_backends() -> std::sync::Arc<std::sync::RwLock<Vec<String>>> {
std::sync::Arc::new(std::sync::RwLock::new(vec![
"127.0.0.1:1".to_string(),
"127.0.0.1:2".to_string(),
]))
}
#[test]
fn dynamic_proxy_round_robin_cycles_backends() {
let dp = DynamicProxy::new(two_backends(), 100, 100, None, None, false, "round_robin".to_string());
let first = pick(&dp, "10.0.0.1");
let second = pick(&dp, "10.0.0.1");
let third = pick(&dp, "10.0.0.1");
assert_ne!(first, second);
assert_eq!(first, third);
}
#[test]
fn dynamic_proxy_default_strategy_is_round_robin() {
let dp = DynamicProxy::new(two_backends(), 100, 100, None, None, false, String::new());
let first = pick(&dp, "10.0.0.1");
let second = pick(&dp, "10.0.0.1");
assert_ne!(first, second);
}
#[test]
fn dynamic_proxy_unknown_strategy_falls_back_to_round_robin() {
let dp = DynamicProxy::new(two_backends(), 100, 100, None, None, false, "not-a-real-strategy".to_string());
let first = pick(&dp, "10.0.0.1");
let second = pick(&dp, "10.0.0.1");
assert_ne!(first, second);
}
#[test]
fn dynamic_proxy_ip_hash_is_sticky_per_client() {
let dp = DynamicProxy::new(two_backends(), 100, 100, None, None, false, "ip_hash".to_string());
let a1 = pick(&dp, "10.0.0.5");
let a2 = pick(&dp, "10.0.0.5");
let a3 = pick(&dp, "10.0.0.5");
assert_eq!(a1, a2);
assert_eq!(a2, a3);
}
#[test]
fn dynamic_proxy_random_always_picks_a_live_backend() {
let live = two_backends();
let dp = DynamicProxy::new(live.clone(), 100, 100, None, None, false, "random".to_string());
for _ in 0..20 {
let backend = pick(&dp, "10.0.0.1");
assert!(live.read().unwrap().contains(&backend));
}
}
#[test]
fn dynamic_proxy_least_connections_avoids_busy_backend() {
let dp = DynamicProxy::new(two_backends(), 100, 100, None, None, false, "least_connections".to_string());
let busy_counter = dp.connection_counter("127.0.0.1:1");
busy_counter.fetch_add(3, std::sync::atomic::Ordering::Relaxed);
let chosen = pick(&dp, "10.0.0.1");
assert_eq!(chosen, "127.0.0.1:2");
}
fn spawn_tagged_backend(tag: &'static str, max_conns: usize) -> u16 {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind backend");
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
use std::io::{Read, Write};
for _ in 0..max_conns {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n{}",
tag.len(),
tag
);
let _ = stream.write_all(response.as_bytes());
}
}
});
port
}
#[test]
fn config_driven_app_ip_hash_strategy_is_sticky_end_to_end() {
let port_a = spawn_tagged_backend("A", 5);
let port_b = spawn_tagged_backend("B", 5);
let cfg = ProxyConfig::from_str(&format!(
r#"
[[upstream]]
name = "backend"
backends = ["127.0.0.1:{port_a}", "127.0.0.1:{port_b}"]
strategy = "ip_hash"
[[route]]
name = "api"
[route.match]
path = "/*"
[route.action]
type = "proxy"
[route.action.proxy]
upstream = "backend"
"#
));
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("10.0.0.42");
let first = app.execute(&make_request("GET", "/x"), &conn).unwrap();
let second = app.execute(&make_request("GET", "/y"), &conn).unwrap();
let body_of = |resp: &crate::response::Response| -> Vec<u8> {
resp.content_range_list.iter().flat_map(|c| c.body.iter().copied()).collect()
};
assert_eq!(first.status_code, 200);
assert_eq!(second.status_code, 200);
assert_eq!(body_of(&first), body_of(&second));
}
#[test]
fn from_str_parses_timeout_ms() {
let cfg = ProxyConfig::from_str(
r#"
[[route]]
name = "slow-upload"
[route.match]
path = "/upload"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "ok"
content_type = "text/plain"
[route.middleware]
timeout_ms = 120000
"#,
);
assert_eq!(Some(120000), cfg.routes[0].middleware.timeout_ms);
}
#[test]
fn timeout_ms_absent_by_default() {
let cfg = ProxyConfig::from_str(
r#"
[[route]]
name = "ping"
[route.match]
path = "/ping"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "pong"
content_type = "text/plain"
"#,
);
assert_eq!(None, cfg.routes[0].middleware.timeout_ms);
}
fn spawn_slow_backend(delay_ms: u64) -> u16 {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind slow backend");
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
use std::io::{Read, Write};
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
let _ = stream.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nslow",
);
}
});
port
}
#[test]
fn route_timeout_ms_cuts_off_a_slow_upstream() {
let port = spawn_slow_backend(300);
let cfg = ProxyConfig::from_str(&format!(
r#"
[[upstream]]
name = "backend"
backends = ["127.0.0.1:{port}"]
[[route]]
name = "api"
[route.match]
path = "/*"
[route.action]
type = "proxy"
[route.action.proxy]
upstream = "backend"
[route.middleware]
timeout_ms = 20
"#
));
let (app, _handles) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let start = std::time::Instant::now();
let resp = app.execute(&make_request("GET", "/x"), &conn).unwrap();
let elapsed = start.elapsed();
assert_eq!(504, resp.status_code);
assert!(elapsed < std::time::Duration::from_millis(150), "took {elapsed:?}, expected well under 150ms");
}
#[test]
fn route_without_timeout_ms_waits_for_the_full_upstream_response() {
let port = spawn_slow_backend(50);
let cfg = ProxyConfig::from_str(&format!(
r#"
[[upstream]]
name = "backend"
backends = ["127.0.0.1:{port}"]
[[route]]
name = "api"
[route.match]
path = "/*"
[route.action]
type = "proxy"
[route.action.proxy]
upstream = "backend"
"#
));
let (app, _handles) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let resp = app.execute(&make_request("GET", "/x"), &conn).unwrap();
assert_eq!(200, resp.status_code);
}
#[test]
fn max_body_size_absent_by_default() {
let cfg = ProxyConfig::from_str(
r#"
[[route]]
name = "ping"
[route.match]
path = "/ping"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "pong"
content_type = "text/plain"
"#,
);
assert_eq!(None, cfg.routes[0].middleware.max_body_size);
}
#[test]
fn from_str_parses_max_body_size() {
let cfg = ProxyConfig::from_str(
r#"
[[route]]
name = "upload"
[route.match]
path = "/upload"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "ok"
[route.middleware]
max_body_size = 1024
"#,
);
assert_eq!(Some(1024), cfg.routes[0].middleware.max_body_size);
}
fn respond_route_with_max_body_size(max_body_size: u64) -> ProxyConfig {
ProxyConfig::from_str(&format!(
r#"
[[route]]
name = "upload"
[route.match]
path = "/*"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "ok"
[route.middleware]
max_body_size = {max_body_size}
"#
))
}
fn request_with_body(body: &[u8]) -> Request {
Request {
method: "POST".to_string(),
request_uri: "/upload".to_string(),
http_version: "HTTP/1.1".to_string(),
headers: vec![],
body: body.to_vec().into(),
}
}
#[test]
fn max_body_size_rejects_oversized_body_with_413() {
let cfg = respond_route_with_max_body_size(10);
let (app, _handles) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let resp = app.execute(&request_with_body(&[b'x'; 11]), &conn).unwrap();
assert_eq!(413, resp.status_code);
let body = String::from_utf8(resp.content_range_list[0].body.clone()).unwrap();
assert!(body.contains("11"), "message should mention the actual body size: {}", body);
assert!(body.contains("10"), "message should mention the configured limit: {}", body);
}
#[test]
fn max_body_size_allows_body_within_limit() {
let cfg = respond_route_with_max_body_size(10);
let (app, _handles) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let resp = app.execute(&request_with_body(&[b'x'; 10]), &conn).unwrap();
assert_eq!(200, resp.status_code, "body exactly at the limit must be allowed");
let resp_empty = app.execute(&request_with_body(&[]), &conn).unwrap();
assert_eq!(200, resp_empty.status_code);
}
#[test]
fn max_body_size_zero_means_unset_not_zero_byte_limit() {
let cfg = respond_route_with_max_body_size(0);
assert_eq!(None, cfg.routes[0].middleware.max_body_size);
let (app, _handles) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let resp = app.execute(&request_with_body(&[b'x'; 5000]), &conn).unwrap();
assert_eq!(200, resp.status_code);
}
#[test]
fn from_str_reads_action_keys_written_directly_under_route_action() {
let cfg = ProxyConfig::from_str(
"[[upstream]]\nname = \"app\"\nbackends = [\"127.0.0.1:9099\"]\n\n\
[[route]]\n [route.match]\n path = \"/*\"\n\n [route.action]\n type = \"proxy\"\n upstream = \"app\"\n\n\
[[route]]\n [route.match]\n path = \"/x\"\n\n [route.action]\n type = \"respond\"\n status = 404\n body = \"Not Found\"\n",
);
match &cfg.routes[0].action {
ActionConfig::Proxy { upstream, .. } => assert_eq!(upstream, "app"),
other => panic!("expected Proxy, got {:?}", other),
}
match &cfg.routes[1].action {
ActionConfig::Respond { status, body, .. } => {
assert_eq!(*status, 404);
assert_eq!(body, "Not Found");
}
other => panic!("expected Respond, got {:?}", other),
}
}
#[test]
fn from_str_nested_action_table_wins_over_short_form() {
let cfg = ProxyConfig::from_str(
"[[route]]\n [route.match]\n path = \"/*\"\n [route.action]\n type = \"proxy\"\n upstream = \"short\"\n connect_timeout_ms = 1000\n [route.action.proxy]\n upstream = \"nested\"\n",
);
match &cfg.routes[0].action {
ActionConfig::Proxy { upstream, connect_timeout_ms, .. } => {
assert_eq!(upstream, "nested");
assert_eq!(*connect_timeout_ms, 1000, "short-form keys absent from the nested table still apply");
}
other => panic!("expected Proxy, got {:?}", other),
}
}
#[test]
fn bearer_auth_fails_closed_when_token_env_is_unset() {
std::env::remove_var("RWSTEST_PROXY_BEARER_UNSET");
let cfg = ProxyConfig::from_str(r#"
[[route]]
[route.match]
path = "/admin"
[route.action]
type = "respond"
body = "secret"
[route.middleware.auth]
type = "bearer"
token_env = "RWSTEST_PROXY_BEARER_UNSET"
"#);
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
let mut req = make_request("GET", "/admin");
req.headers.push(crate::header::Header { name: "Authorization".into(), value: "Bearer ".into() });
let resp = app.execute(&req, &conn).unwrap();
assert_eq!(503, resp.status_code);
let body: Vec<u8> = resp.content_range_list.iter().flat_map(|c| c.body.clone()).collect();
assert!(!String::from_utf8_lossy(&body).contains("secret"));
}
#[test]
fn constant_time_eq_matches_only_identical_bytes() {
use crate::proxy_config::constant_time_eq;
assert!(constant_time_eq(b"Bearer abc", b"Bearer abc"));
assert!(!constant_time_eq(b"Bearer abc", b"Bearer abd"));
assert!(!constant_time_eq(b"Bearer abc", b"Bearer abcd"));
assert!(constant_time_eq(b"", b""));
}
#[cfg(not(feature = "auth"))]
#[test]
fn jwt_route_fails_closed_in_a_build_without_the_auth_feature() {
std::env::set_var("RWSTEST_PROXY_JWT_NOFEATURE", "s3cret");
let cfg = ProxyConfig::from_str(r#"
[[route]]
[route.match]
path = "/secret"
[route.action]
type = "respond"
body = "secret"
[route.middleware.auth]
type = "jwt"
secret_env = "RWSTEST_PROXY_JWT_NOFEATURE"
"#);
let (app, _) = crate::proxy_config::builder::build(cfg);
let resp = app.execute(&make_request("GET", "/secret"), &make_conn("127.0.0.1")).unwrap();
assert_eq!(503, resp.status_code);
}
#[test]
fn from_str_survives_a_lone_quote_value() {
let _ = ProxyConfig::from_str("J='\nK=\"\n[[route]]\npath = '\n");
}
#[test]
fn inline_middleware_tables_are_parsed() {
let cfg = ProxyConfig::from_str(r#"
[[route]]
[route.match]
path = "/api/*"
[route.action]
type = "respond"
[route.middleware]
rate_limit = { max_requests = 500, window_secs = 60 }
auth = { type = "bearer", token_env = "API_TOKEN" }
ip_filter = { allow = ["10.0.0.0/8"] }
cache = { ttl_secs = 30 }
"#);
let mw = &cfg.routes[0].middleware;
let rl = mw.rate_limit.as_ref().expect("inline rate_limit");
assert_eq!((rl.max_requests, rl.window_secs), (500, 60));
assert!(matches!(mw.auth, Some(crate::proxy_config::AuthConfig::Bearer { ref token_env }) if token_env == "API_TOKEN"));
assert_eq!(mw.ip_allow, vec!["10.0.0.0/8".to_string()]);
assert_eq!(mw.cache.as_ref().expect("inline cache").ttl_secs, 30);
}
#[test]
fn inline_bearer_auth_protects_the_route() {
std::env::set_var("RWSTEST_PROXY_BEARER_INLINE", "inline-token");
let cfg = ProxyConfig::from_str(r#"
[[route]]
[route.match]
path = "/secret"
[route.action]
type = "respond"
body = "secret"
[route.middleware]
auth = { type = "bearer", token_env = "RWSTEST_PROXY_BEARER_INLINE" }
"#);
let (app, _) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
assert_eq!(app.execute(&make_request("GET", "/secret"), &conn).unwrap().status_code, 401);
let mut authorized = make_request("GET", "/secret");
authorized.headers.push(crate::header::Header { name: "Authorization".into(), value: "Bearer inline-token".into() });
assert_eq!(app.execute(&authorized, &conn).unwrap().status_code, 200);
}
#[test]
fn global_middleware_applies_to_routes_and_the_fallback() {
let cfg = ProxyConfig::from_str(r#"
[middleware]
ip_filter = { deny = ["10.0.0.0/8"] }
[[route]]
[route.match]
path = "/api"
[route.action]
type = "respond"
body = "api"
"#);
assert_eq!(cfg.global_middleware.ip_deny, vec!["10.0.0.0/8".to_string()]);
let (app, _) = crate::proxy_config::builder::build(cfg);
let app = app.with_config(crate::server_config::ServerConfig::default());
let blocked = make_conn("10.1.2.3");
let allowed = make_conn("192.0.2.1");
assert_eq!(app.execute(&make_request("GET", "/api"), &blocked).unwrap().status_code, 403);
assert_eq!(app.execute(&make_request("GET", "/healthz"), &blocked).unwrap().status_code, 403);
assert_eq!(app.execute(&make_request("GET", "/api"), &allowed).unwrap().status_code, 200);
assert_eq!(app.execute(&make_request("GET", "/healthz"), &allowed).unwrap().status_code, 200);
}
fn respond_config(body: &str) -> String {
format!(r#"
[[route]]
[route.match]
path = "/v"
[route.action]
type = "respond"
body = "{body}"
"#)
}
fn body_of(app: &crate::proxy_config::ConfigDrivenApp, path: &str) -> String {
let response = app.execute(&make_request("GET", path), &make_conn("127.0.0.1")).unwrap();
response.content_range_list.iter().map(|r| String::from_utf8_lossy(&r.body).to_string()).collect()
}
#[test]
fn reload_swaps_routes_for_every_clone() {
let (app, _) = crate::proxy_config::builder::build(ProxyConfig::from_str(&respond_config("v1")));
let clone = app.clone();
assert_eq!(body_of(&clone, "/v"), "v1");
let outcome = app.reload_from_str(&respond_config("v2")).unwrap();
assert_eq!(outcome, crate::proxy_config::ReloadOutcome::Reloaded { routes: 1, warnings: vec![] });
assert_eq!(body_of(&clone, "/v"), "v2");
assert_eq!(app.reload_from_str(&respond_config("v2")).unwrap(), crate::proxy_config::ReloadOutcome::Unchanged);
}
#[test]
fn reload_with_errors_keeps_the_running_routes() {
let (app, _) = crate::proxy_config::builder::build(ProxyConfig::from_str(&respond_config("v1")));
let broken = respond_config("v2").replace("type = \"respond\"", "type = \"respnd\"");
let report = app.reload_from_str(&broken).unwrap_err();
assert!(report.errors.iter().any(|e| e.contains("unknown action type 'respnd'")), "{:?}", report.errors);
assert_eq!(body_of(&app, "/v"), "v1");
}
#[test]
fn reload_stops_the_replaced_health_checkers() {
let config = r#"
[[upstream]]
name = "app"
backends = ["127.0.0.1:9"]
[upstream.health_check]
interval_secs = 3600
[[route]]
[route.action]
type = "proxy"
upstream = "app"
"#;
let (app, _) = crate::proxy_config::builder::build(ProxyConfig::from_str(config));
let first = app.current();
assert!(!first.health_stop.load(std::sync::atomic::Ordering::Relaxed));
app.reload_from_str(&config.replace("3600", "3601")).unwrap();
assert!(first.health_stop.load(std::sync::atomic::Ordering::Relaxed));
assert!(!app.current().health_stop.load(std::sync::atomic::Ordering::Relaxed));
}
#[test]
fn reload_warns_that_listener_changes_need_a_restart() {
let (app, _) = crate::proxy_config::builder::build(ProxyConfig::from_str(&respond_config("v1")));
let with_listener = format!("{}\n[[tcp_proxy]]\nname = \"db\"\nlisten = \"127.0.0.1:0\"\nbackends = [\"127.0.0.1:5432\"]\n", respond_config("v1"));
match app.reload_from_str(&with_listener).unwrap() {
crate::proxy_config::ReloadOutcome::Reloaded { warnings, .. } => {
assert!(warnings.iter().any(|w| w.contains("need a restart")), "{warnings:?}");
}
other => panic!("{other:?}"),
}
}
#[test]
fn reload_keeps_a_pinned_fallback_config() {
let (app, _) = crate::proxy_config::builder::build(ProxyConfig::from_str(&respond_config("v1")));
let app = app.with_config(crate::server_config::ServerConfig::default());
app.reload_from_str(&respond_config("v2")).unwrap();
assert_eq!(body_of(&app, "/v"), "v2");
let fallback = app.execute(&make_request("GET", "/healthz"), &make_conn("127.0.0.1")).unwrap();
assert_eq!(fallback.status_code, 200);
}
#[cfg(feature = "http2")]
#[test]
fn an_undeclared_https_upstream_is_reached_over_tls() {
use std::io::Read;
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut first = [0u8; 1];
let _ = stream.read_exact(&mut first);
let _ = tx.send(first[0]);
}
});
let cfg = ProxyConfig::from_str(&format!(r#"
[[route]]
[route.action]
type = "proxy"
upstream = "https://127.0.0.1:{port}"
connect_timeout_ms = 2000
read_timeout_ms = 2000
"#));
let (app, _) = crate::proxy_config::builder::build(cfg);
let _ = app.execute(&make_request("GET", "/"), &make_conn("127.0.0.1"));
assert_eq!(0x16, rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap());
}
#[cfg(feature = "http2")]
#[test]
fn upstream_tls_ca_file_trusts_a_private_ca_and_fails_closed_when_unloadable() {
let certified = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
let ca = crate::test_env::TempPath::new("pc_private_ca.pem");
std::fs::write(ca.as_str(), certified.cert.pem()).unwrap();
let key = rustls::pki_types::PrivateKeyDer::try_from(certified.signing_key.serialize_der()).unwrap();
let server_config = std::sync::Arc::new(
rustls::ServerConfig::builder_with_provider(std::sync::Arc::new(rustls::crypto::aws_lc_rs::default_provider()))
.with_safe_default_protocol_versions().unwrap()
.with_no_client_auth()
.with_single_cert(vec![rustls::pki_types::CertificateDer::from(certified.cert.der().to_vec())], key)
.unwrap(),
);
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let server_config = std::sync::Arc::clone(&server_config);
std::thread::spawn(move || {
use std::io::{Read, Write};
let mut tls = rustls::StreamOwned::new(rustls::ServerConnection::new(server_config).unwrap(), stream);
let mut buf = [0u8; 4096];
let mut head = Vec::new();
while !head.windows(4).any(|w| w == b"\r\n\r\n") {
match tls.read(&mut buf) {
Ok(0) | Err(_) => return,
Ok(n) => head.extend_from_slice(&buf[..n]),
}
}
let _ = tls.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\nprivate");
let _ = tls.flush();
});
}
});
let config_for = |ca_file: &str| ProxyConfig::from_str(&format!(r#"
[[upstream]]
name = "internal"
backends = ["https://localhost:{port}"]
tls_ca_file = "{ca_file}"
[[route]]
[route.action]
type = "proxy"
upstream = "internal"
read_timeout_ms = 2000
"#));
let (app, _) = crate::proxy_config::builder::build(config_for(ca.as_str()));
let response = app.execute(&make_request("GET", "/"), &make_conn("127.0.0.1")).unwrap();
assert_eq!(response.status_code, 200);
let (app, _) = crate::proxy_config::builder::build(config_for("/nonexistent/ca.pem"));
let response = app.execute(&make_request("GET", "/"), &make_conn("127.0.0.1")).unwrap();
assert_eq!(response.status_code, 502);
let mut report = crate::config_check::ConfigReport::default();
config_for("/nonexistent/ca.pem").validate(&mut report);
assert!(report.errors.iter().any(|e| e.contains("cannot read CA file")), "{:?}", report.errors);
}
#[test]
fn tls_ca_file_on_a_plain_upstream_is_a_warning() {
let mut report = crate::config_check::ConfigReport::default();
ProxyConfig::from_str("[[upstream]]\nname = \"app\"\nbackends = [\"127.0.0.1:3000\"]\ntls_ca_file = \"/x.pem\"\n").validate(&mut report);
assert!(report.warnings.iter().any(|w| w.contains("aren't used")), "{:?}", report.warnings);
}
#[test]
fn bypass_spellings_reach_the_protected_route() {
let cfg = ProxyConfig::from_str(
r#"
[[route]]
name = "admin"
[route.match]
path = "/admin/*"
[route.action]
type = "respond"
[route.action.respond]
status = 403
body = "protected"
[[route]]
name = "catch-all"
[route.match]
path = "/*"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "public"
"#,
);
let (app, _handles) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
for uri in ["/admin/users", "/%61dmin/users", "/%61%64%6D%69%6E/users", "//admin/users", "/x/../admin/users", "/./admin/users", "/admin//users", "/x/%2e%2e/admin/users", "/admin/users?a=1"] {
let resp = app.execute(&make_request("GET", uri), &conn).unwrap();
assert_eq!(403, resp.status_code, "{uri} skipped the protected route");
}
for uri in ["/public", "/administrator", "/%2Fadmin/users"] {
let resp = app.execute(&make_request("GET", uri), &conn).unwrap();
assert_eq!(200, resp.status_code, "{uri}");
}
}
#[test]
fn request_targets_normalize_like_rfc_3986() {
use crate::proxy_config::normalize_request_target as n;
for (input, expected) in [
("/a/b", "/a/b"),
("/%61%62", "/ab"),
("/%7e%2d%2E%5F", "/~-._"),
("/a%2fb", "/a%2Fb"),
("/a%20b", "/a%20b"),
("//a///b/", "/a/b/"),
("/a/./b/../c", "/a/c"),
("/../../etc", "/etc"),
("/a/..", "/"),
("/a/b/.", "/a/b/"),
("/a?x=../y//z", "/a?x=../y//z"),
("/%zz/%4", "/%25zz/%254"),
("/*26%%611", "/*26%25a1"),
("/caf\u{e9}/./x", "/caf\u{e9}/x"),
("*", "*"),
] {
assert_eq!(expected, n(input), "{input}");
}
assert!(matches!(n("/already/clean?q=1"), std::borrow::Cow::Borrowed(_)));
}
#[test]
fn route_hosts_match_without_case_or_port() {
let cfg = ProxyConfig::from_str(
r#"
[[route]]
name = "site"
[route.match]
host = "example.com"
path = "/*"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "site"
"#,
);
let (app, _handles) = crate::proxy_config::builder::build(cfg);
let conn = make_conn("127.0.0.1");
for host in ["example.com", "EXAMPLE.com", "example.com:8443"] {
let mut req = make_request("GET", "/");
req.headers.push(Header { name: "Host".into(), value: host.to_string().into() });
let body: Vec<u8> = app.execute(&req, &conn).unwrap().content_range_list.into_iter().flat_map(|cr| cr.body).collect();
assert_eq!(b"site".to_vec(), body, "{host}");
}
let mut other = make_request("GET", "/");
other.headers.push(Header { name: "Host".into(), value: "example.com.evil".into() });
let body: Vec<u8> = app.execute(&other, &conn).unwrap().content_range_list.into_iter().flat_map(|cr| cr.body).collect();
assert_ne!(b"site".to_vec(), body);
}
#[cfg(feature = "http2")]
#[test]
fn upstream_client_certificate_keys_are_checked() {
let toml = |extra: &str| format!("[[upstream]]\nname = \"api\"\nbackends = [\"grpcs://api.internal:443\"]\n{extra}");
let mut report = crate::config_check::ConfigReport::default();
ProxyConfig::from_str(&toml("")).validate(&mut report);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert!(ProxyConfig::from_str(&toml("")).upstreams[0].tls);
let mut report = crate::config_check::ConfigReport::default();
ProxyConfig::from_str(&toml("tls_client_cert_file = \"/x.pem\"\n")).validate(&mut report);
assert!(report.errors.iter().any(|e| e.contains("go together")), "{:?}", report.errors);
let mut report = crate::config_check::ConfigReport::default();
ProxyConfig::from_str(&toml("tls_client_cert_file = \"/x.pem\"\ntls_client_key_file = \"/x.key\"\n")).validate(&mut report);
assert!(report.errors.iter().any(|e| e.contains("client certificate")), "{:?}", report.errors);
let mut report = crate::config_check::ConfigReport::default();
crate::proxy_config::validate::check_unknown_keys(&toml("tls_client_cert_file = \"/x.pem\"\ntls_client_key_file = \"/x.key\"\n"), &mut report);
assert!(report.warnings.is_empty() && report.errors.is_empty(), "{:?} {:?}", report.warnings, report.errors);
}
fn pick(dp: &DynamicProxy, client_ip: &str) -> String {
let request = Request {
method: "GET".to_string(),
request_uri: "/".to_string(),
http_version: "HTTP/1.1".to_string(),
headers: vec![],
body: Default::default(),
};
dp.next_backend(&request, client_ip, &[]).expect("a backend")
}