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")
}
#[test]
fn route_matcher_prefers_the_host_header_over_sni() {
let git = RouteMatcher::from_match_config(&MatchConfig { host: Some("git.example.com".to_string()), ..Default::default() });
let site = RouteMatcher::from_match_config(&MatchConfig { host: Some("example.com".to_string()), ..Default::default() });
let mut conn = make_conn("127.0.0.1");
conn.sni_hostname = Some("git.example.com".to_string());
let mut req = make_request("GET", "/");
req.headers.push(Header { name: "Host".into(), value: "example.com".to_string().into() });
assert!(!git.matches(&req, &conn));
assert!(site.matches(&req, &conn));
req.headers[0].value = "".to_string().into();
assert!(git.matches(&req, &conn));
}
#[test]
fn basic_strings_decode_escapes_literal_strings_do_not() {
let cfg = ProxyConfig::from_str(concat!(
"[[route]]\nname = \"policy\"\n[route.match]\npath = \"/p\"\n[route.action]\ntype = \"respond\"\n",
"body = \"version: STSv1\\r\\nmode: testing\\r\\n\\\"q\\\" \\u00e9\\U0001F600 \\\\ \\d\"\n",
"[[route]]\nname = \"literal\"\n[route.match]\npath = \"/l\"\n[route.action]\ntype = \"respond\"\n",
"body = 'C:\\new\\path' # a comment\n",
));
let bodies: Vec<String> = cfg.routes.iter().map(|r| match &r.action {
ActionConfig::Respond { body, .. } => body.clone(),
other => panic!("{other:?}"),
}).collect();
assert_eq!(bodies[0], "version: STSv1\r\nmode: testing\r\n\"q\" é😀 \\ \\d");
assert_eq!(bodies[1], "C:\\new\\path");
}
#[test]
fn an_escaped_quote_does_not_end_a_string_for_comments_arrays_or_inline_tables() {
let cfg = ProxyConfig::from_str(concat!(
"[[upstream]]\nname = \"u\"\nbackends = [\"127.0.0.1:1\", \"127.0.0.1:2\"] # two\n",
"[[route]]\nname = \"r\"\n[route.match]\npath = \"/\"\n[route.action]\ntype = \"respond\"\n",
"body = \"a \\\"#not a comment\\\", b\" # a comment\n",
"[route.middleware]\nrate_limit = { max_requests = 5, window_secs = 60 }\n",
));
assert_eq!(cfg.upstreams[0].backends, ["127.0.0.1:1", "127.0.0.1:2"]);
match &cfg.routes[0].action {
ActionConfig::Respond { body, .. } => assert_eq!(body, "a \"#not a comment\", b"),
other => panic!("{other:?}"),
}
}
#[test]
fn inline_tables_keep_non_ascii_text() {
let map = crate::proxy_config::parser::parse("[t]\nx = { a = \"żółw ✓\", b = 'ünï' }\n");
let vals: Vec<&str> = map["t.x"].iter().map(|(_, v)| v.as_str()).collect();
assert_eq!(vals, ["żółw ✓", "ünï"]);
}
fn spawn_head_echo_backend(n: 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..n {
let Ok((mut stream, _)) = listener.accept() else { return };
let mut buf = vec![0u8; 8192];
let len = stream.read(&mut buf).unwrap_or(0);
let head = String::from_utf8_lossy(&buf[..len]).to_string();
let response = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", head.len(), head);
let _ = stream.write_all(response.as_bytes());
}
});
port
}
fn host_route(port: u16, action_extra: &str) -> crate::proxy_config::ConfigDrivenApp {
let cfg = ProxyConfig::from_str(&format!(
"[[upstream]]\nname = \"b\"\nbackends = [\"127.0.0.1:{port}\"]\n[[route]]\nname = \"r\"\n[route.match]\npath = \"/*\"\n[route.action]\ntype = \"proxy\"\nupstream = \"b\"\n{action_extra}"
));
crate::proxy_config::builder::build(cfg).0
}
fn request_for(host: &str) -> Request {
let mut req = make_request("GET", "/x");
req.headers.push(Header { name: "Host".into(), value: host.to_string().into() });
req
}
fn upstream_host_line(response: &crate::response::Response) -> String {
let body: Vec<u8> = response.content_range_list.iter().flat_map(|c| c.body.iter().copied()).collect();
String::from_utf8_lossy(&body).lines().find(|l| l.to_ascii_lowercase().starts_with("host:")).unwrap_or("").to_string()
}
#[test]
fn proxy_action_parses_preserve_host_and_upstream_host() {
use crate::proxy_config::UpstreamHost;
let host_of = |extra: &str| {
let cfg = ProxyConfig::from_str(&format!("[[route]]\n[route.match]\npath = \"/\"\n[route.action]\ntype = \"proxy\"\nupstream = \"127.0.0.1:1\"\n{extra}"));
match &cfg.routes[0].action {
ActionConfig::Proxy { upstream_host, .. } => upstream_host.clone(),
other => panic!("{other:?}"),
}
};
assert_eq!(host_of(""), UpstreamHost::Backend);
assert_eq!(host_of("preserve_host = true"), UpstreamHost::Client);
assert_eq!(host_of("upstream_host = \"git.example.com\""), UpstreamHost::Fixed("git.example.com".into()));
assert_eq!(host_of("preserve_host = true\nupstream_host = \"a.example\""), UpstreamHost::Fixed("a.example".into()));
}
#[test]
fn proxy_sends_the_configured_host_upstream() {
let conn = make_conn("10.0.0.1");
let port = spawn_head_echo_backend(4);
let backend = format!("Host: 127.0.0.1");
assert_eq!(upstream_host_line(&host_route(port, "").execute(&request_for("git.example.com"), &conn).unwrap()), backend);
assert_eq!(upstream_host_line(&host_route(port, "preserve_host = true").execute(&request_for("git.example.com"), &conn).unwrap()), "Host: git.example.com");
assert_eq!(upstream_host_line(&host_route(port, "upstream_host = \"fixed.example\"").execute(&request_for("git.example.com"), &conn).unwrap()), "Host: fixed.example");
assert_eq!(upstream_host_line(&host_route(port, "preserve_host = true").execute(&make_request("GET", "/x"), &conn).unwrap()), backend);
}
#[cfg(feature = "http2")]
#[test]
fn async_forwarder_sends_the_configured_host_upstream() {
let conn = make_conn("10.0.0.1");
let port = spawn_head_echo_backend(2);
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
for (extra, want) in [("preserve_host = true", "Host: git.example.com"), ("upstream_host = \"fixed.example\"", "Host: fixed.example")] {
let app = host_route(port, extra);
let request = request_for("git.example.com");
let response = runtime.block_on(async { app.execute_async(&request, &conn).expect("async route").await }).unwrap();
assert_eq!(upstream_host_line(&response), want);
}
}
#[test]
fn rws_check_warns_that_a_host_rewrite_does_not_reach_the_upstream() {
let base = "[[route]]\n[route.match]\npath = \"/\"\n[route.action]\ntype = \"proxy\"\nupstream = \"127.0.0.1:1\"\n";
let rewrite = "[[route.middleware.rewrite.request]]\ntype = \"header_set\"\nname = \"Host\"\nvalue = \"a.example\"\n";
let warns = |toml: &str| crate::config_check::check_str_offline(toml).warnings.join("\n");
assert!(warns(&format!("{base}{rewrite}")).contains("preserve_host"));
assert!(!warns(base).contains("preserve_host"));
}
#[cfg(feature = "auth")]
#[test]
fn jwt_auth_accepts_the_previous_secret_while_rotating() {
let _g = crate::test_env::lock();
std::env::set_var("RWS_TEST_JWT_NEW", "new-secret");
std::env::set_var("RWS_TEST_JWT_OLD", "old-secret");
let toml = r#"
[[route]]
[route.match]
path = "/p"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "ok"
[route.middleware.auth]
type = "jwt"
secret_env = "RWS_TEST_JWT_NEW"
previous_secret_env = "RWS_TEST_JWT_OLD"
"#;
let cfg = ProxyConfig::from_str(toml);
match cfg.routes[0].middleware.auth.as_ref().unwrap() {
crate::proxy_config::AuthConfig::Jwt { previous_secret_env, .. } => assert_eq!(previous_secret_env, "RWS_TEST_JWT_OLD"),
other => panic!("{other:?}"),
}
assert!(!format!("{:?}", crate::config_check::check_str_offline(toml)).contains("unknown key"));
let app = crate::proxy_config::builder::build(cfg).0;
let call = |secret: &[u8]| {
let token = crate::auth::build_jwt(r#"{"sub":"a"}"#, secret);
let raw = format!("GET /p HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer {token}\r\n\r\n");
app.execute(&crate::request::Request::parse(raw.as_bytes()).unwrap(), &crate::server::ConnectionInfo::default()).unwrap().status_code
};
assert_eq!((200, 200, 401), (call(b"new-secret"), call(b"old-secret"), call(b"other")));
std::env::remove_var("RWS_TEST_JWT_NEW");
std::env::remove_var("RWS_TEST_JWT_OLD");
}
#[test]
fn fastcgi_action_parses_short_and_nested_forms_with_params() {
let cfg = ProxyConfig::from_str(r#"
[[route]]
[route.match]
path = "/*"
[route.action]
type = "fastcgi"
address = "unix:/run/php/fpm.sock"
root = "/var/www/app/public"
front_controller = ""
[route.action.params]
APP_ENV = "prod"
APP_DEBUG = "0"
"#);
let ActionConfig::FastCgi(f) = &cfg.routes[0].action else { panic!("{:?}", cfg.routes[0].action) };
assert_eq!(f.address, "unix:/run/php/fpm.sock");
assert_eq!(f.root, "/var/www/app/public");
assert_eq!(f.index, ["index.php"]);
assert_eq!(f.split_path, ".php");
assert_eq!(f.front_controller, "", "an explicit empty value turns it off");
assert_eq!(f.read_timeout_ms, 60000);
assert!(f.params.contains(&("APP_ENV".to_string(), "prod".to_string())) && f.params.len() == 2, "{:?}", f.params);
let cfg = ProxyConfig::from_str(r#"
[[route]]
[route.match]
path = "/*"
[route.action]
type = "fastcgi"
[route.action.fastcgi]
address = "127.0.0.1:9000"
root = "public"
index = ["index.php", "app.php"]
read_timeout_ms = 5000
"#);
let ActionConfig::FastCgi(f) = &cfg.routes[0].action else { panic!() };
assert_eq!((f.address.as_str(), f.index.len(), f.read_timeout_ms, f.front_controller.as_str()), ("127.0.0.1:9000", 2, 5000, "index.php"));
}
#[test]
fn fastcgi_action_is_validated() {
let report = crate::config_check::check_str_offline("[[route]]\n[route.match]\npath = \"/*\"\n[route.action]\ntype = \"fastcgi\"\naddress = \"php\"\n[route.action.params]\nANY_NAME = \"x\"\n");
let text = format!("{report:?}");
assert!(text.contains("expected unix:/path or host:port"), "{text}");
assert!(text.contains("fastcgi action has no `root`"), "{text}");
assert!(!text.contains("unknown"), "params take any key: {text}");
}
#[test]
fn oidc_forward_and_unknown_auth_types_parse() {
let route = |auth: &str| format!("[[route]]\n[route.match]\npath = \"/*\"\n[route.action]\ntype = \"respond\"\nstatus = 200\n[route.middleware.auth]\n{auth}\n");
let cfg = ProxyConfig::from_str(&route(r#"type = "oidc"
issuer = "https://id.example.com/realms/main/"
client_id = "rws"
client_secret_env = "OIDC_SECRET"
allowed_domains = ["example.com"]
allowed_groups = ["ops", "sre"]
groups_claim = "roles"
cookie_secret_env = "OIDC_COOKIE"
exclude = ["/healthz"]
forward_headers = false"#));
let Some(crate::proxy_config::AuthConfig::Oidc(o)) = &cfg.routes[0].middleware.auth else { panic!("{:?}", cfg.routes[0].middleware.auth) };
assert_eq!(o.issuer, "https://id.example.com/realms/main", "trailing slash trimmed");
assert_eq!((o.client_id.as_str(), o.client_secret_env.as_str(), o.groups_claim.as_str()), ("rws", "OIDC_SECRET", "roles"));
assert_eq!(o.allowed_groups, ["ops", "sre"]);
assert_eq!(o.exclude, ["/healthz"]);
assert!(!o.forward_headers);
assert_eq!(o.session_ttl_secs, 86_400);
let cfg = ProxyConfig::from_str(&route("type = \"forward\"\nurl = \"http://authelia:9091/api/verify\"\ncopy_headers = [\"Remote-User\", \"Remote-Groups\"]"));
assert_eq!(
cfg.routes[0].middleware.auth,
Some(crate::proxy_config::AuthConfig::Forward { url: "http://authelia:9091/api/verify".to_string(), copy_headers: vec!["Remote-User".to_string(), "Remote-Groups".to_string()], timeout_ms: 5000 })
);
let cfg = ProxyConfig::from_str(&route("type = \"oauth\""));
assert_eq!(cfg.routes[0].middleware.auth, Some(crate::proxy_config::AuthConfig::Unknown("oauth".to_string())));
}
#[test]
fn oidc_forward_and_unknown_auth_are_validated() {
let route = |auth: &str| format!("[[route]]\n[route.match]\npath = \"/*\"\n[route.action]\ntype = \"respond\"\nstatus = 200\n[route.middleware.auth]\n{auth}\n");
let text = |toml: String| format!("{:?}", crate::config_check::check_str_offline(&toml));
let t = text(route("type = \"oidc\"\nprovider = \"yahoo\""));
assert!(t.contains("no `client_id`") && t.contains("oidc provider \\\"yahoo\\\""), "{t}");
assert!(t.contains("without `cookie_secret_env`"), "{t}");
let report = crate::config_check::check_str_offline(&route("type = \"oidc\"\nprovider = \"google\"\nclient_id = \"x\"\nclient_secret_env = \"S\"\ncookie_secret_env = \"C\""));
let errors: Vec<_> = format!("{report:?}").lines().map(str::to_string).collect();
if cfg!(feature = "sso") {
assert!(report.is_ok(), "{errors:?}");
} else {
assert!(format!("{report:?}").contains("needs the `sso` feature"), "{errors:?}");
}
assert!(text(route("type = \"forward\"\nurl = \"authelia:9091\"")).contains("not an http(s) URL"));
assert!(text(route("type = \"oauth\"")).contains("auth type \\\"oauth\\\" is not one of"));
}
#[test]
fn an_unknown_auth_type_fails_closed() {
let cfg = ProxyConfig::from_str("[[route]]\n[route.match]\npath = \"/*\"\n[route.action]\ntype = \"respond\"\nstatus = 200\nbody = \"secret\"\n[route.middleware.auth]\ntype = \"oauth\"\n");
let app = crate::proxy_config::builder::build(cfg).0;
let res = crate::test_client::TestClient::new(app).get("/x").send();
assert_eq!(res.status(), 503);
}
#[test]
fn proxy_routes_relay_websocket_upgrades() {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let seen = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
let seen_by_backend = std::sync::Arc::clone(&seen);
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut head = Vec::new();
let mut byte = [0u8; 1];
while !head.ends_with(b"\r\n\r\n") && stream.read(&mut byte).unwrap() == 1 {
head.push(byte[0]);
}
*seen_by_backend.lock().unwrap() = String::from_utf8_lossy(&head).to_string();
stream.write_all(b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: abc\r\n\r\nhello").unwrap();
let mut buf = [0u8; 64];
while let Ok(n) = stream.read(&mut buf) {
if n == 0 || stream.write_all(&buf[..n]).is_err() {
break;
}
}
});
let cfg = ProxyConfig::from_str(&format!("[[route]]\n[route.match]\npath = \"/ws/*\"\n[route.action]\ntype = \"proxy\"\nupstream = \"127.0.0.1:{port}\"\nstrip_path_prefix = \"/ws\"\n"));
let app = crate::proxy_config::builder::build(cfg).0;
let mut req = make_request("GET", "/ws/chat?room=1");
for (n, v) in [("Host", "app.test"), ("Connection", "Upgrade"), ("Upgrade", "websocket"), ("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="), ("Sec-WebSocket-Version", "13")] {
req.headers.push(Header { name: n.into(), value: v.into() });
}
let mut response = app.execute(&req, &make_conn("203.0.113.9")).unwrap();
assert_eq!(response.status_code, 101);
assert!(response.headers.iter().any(|h| h.name.eq_ignore_ascii_case("sec-websocket-accept") && &*h.value == "abc"));
let head = seen.lock().unwrap().clone();
assert!(head.starts_with("GET /chat?room=1 HTTP/1.1\r\n"), "path rewriting applies: {head}");
assert!(head.contains("Upgrade: websocket") && head.contains("Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ=="), "{head}");
assert!(head.contains("X-Forwarded-For: 203.0.113.9"), "{head}");
let upgrade = crate::websocket::take_upgrade(&mut response).expect("a relay waits for the connection");
let client_to_backend = std::io::Cursor::new(b"ping".to_vec());
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
struct Sink(std::sync::mpsc::Sender<Vec<u8>>);
impl Write for Sink {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
let _ = self.0.send(b.to_vec());
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
std::thread::spawn(move || upgrade.run(Box::new(client_to_backend), Box::new(Sink(tx))));
let mut got = Vec::new();
while !got.ends_with(b"helloping") {
got.extend(rx.recv_timeout(std::time::Duration::from_secs(5)).expect("relayed bytes"));
}
assert_eq!(got, b"helloping", "bytes after the 101, then the echo");
}
#[test]
fn an_upgrade_to_a_dead_backend_is_a_502() {
let cfg = ProxyConfig::from_str("[[route]]\n[route.match]\npath = \"/*\"\n[route.action]\ntype = \"proxy\"\nupstream = \"127.0.0.1:1\"\n");
let app = crate::proxy_config::builder::build(cfg).0;
let mut req = make_request("GET", "/ws");
req.headers.push(Header { name: "Connection".into(), value: "keep-alive, Upgrade".into() });
req.headers.push(Header { name: "Upgrade".into(), value: "websocket".into() });
assert_eq!(app.execute(&req, &make_conn("127.0.0.1")).unwrap().status_code, 502);
}