use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use ytsaurus_client::{Client, ClientError, RetryPolicy};
#[test]
fn a_cross_origin_hosts_redirect_no_longer_permanently_disables_routing() {
let control = Proxy::new(Hosts::RedirectCrossOrigin);
let client = Client::with_token(&control.url(), "secret-token")
.with_retries(RetryPolicy::none())
.with_proxy_discovery(true)
.with_hosts_retry_after(Duration::ZERO);
for _ in 0..3 {
client
.write_table("//tmp/t", b"")
.expect("the upload still succeeds");
}
let hosts_lookups = control
.requests()
.iter()
.filter(|line| line.starts_with("GET /hosts"))
.count();
assert_eq!(
hosts_lookups,
3,
"the redirect was cached as a permanent verdict again: {:?}",
control.requests()
);
}
#[test]
fn the_same_permanence_is_gone_with_a_ten_second_default_too() {
let control = Proxy::new(Hosts::RedirectCrossOrigin);
let client = Client::with_token(&control.url(), "secret-token")
.with_retries(RetryPolicy::none())
.with_proxy_discovery(true);
for _ in 0..3 {
client
.write_table("//tmp/t", b"")
.expect("still succeeds via fallback");
}
let hosts_lookups = control
.requests()
.iter()
.filter(|line| line.starts_with("GET /hosts"))
.count();
assert_eq!(hosts_lookups, 1, "{:?}", control.requests());
}
#[test]
fn a_same_origin_hosts_redirect_is_now_followed_and_routing_works() {
let heavy = Proxy::new(Hosts::naming_itself());
let control = Proxy::new(Hosts::RedirectSameOriginTo(format!(
r#"["{}"]"#,
heavy.host()
)));
Client::with_token(&control.url(), "secret-token")
.with_retries(RetryPolicy::none())
.with_proxy_discovery(true)
.write_table("//tmp/t", b"")
.expect("writes");
assert!(
control
.requests()
.iter()
.any(|l| l.starts_with("GET /hosts")),
"the lookup did not happen: {:?}",
control.requests()
);
assert!(
heavy
.requests()
.iter()
.any(|l| l.contains("/api/v4/write_table")),
"the upload did not reach the heavy proxy: heavy={:?} control={:?}",
heavy.requests(),
control.requests()
);
}
#[test]
fn the_redirected_lookup_is_still_reported_as_a_redirect_when_asked_directly() {
let control = Proxy::new(Hosts::RedirectCrossOrigin);
let error = Client::with_token(&control.url(), "secret-token")
.with_retries(RetryPolicy::none())
.with_proxy_discovery(true)
.heavy_proxy()
.expect_err("the balancer redirected cross-origin");
assert!(
matches!(error, ClientError::Redirected { .. }),
"the lookup failed as something other than a redirect: {error:?}"
);
}
#[test]
fn a_discovered_proxy_that_fails_non_retriably_is_not_re_resolved() {
let heavy = Proxy::new(Hosts::naming_itself().failing_commands_with(500));
let control = Proxy::new(Hosts::listing(&heavy.host()));
let client = Client::with_token(&control.url(), "secret-token")
.with_retries(RetryPolicy::none())
.with_proxy_discovery(true)
.with_hosts_retry_after(Duration::ZERO);
for _ in 0..3 {
let _ = client.write_table("//tmp/t", b"");
}
let hosts_lookups = control
.requests()
.iter()
.filter(|l| l.starts_with("GET /hosts"))
.count();
assert_eq!(
hosts_lookups,
1,
"a non-retriable failure re-resolved: {:?}",
control.requests()
);
assert_eq!(
heavy
.requests()
.iter()
.filter(|l| l.contains("/api/v4/write_table"))
.count(),
3,
"{:?}",
heavy.requests()
);
}
#[test]
fn a_discovered_proxy_that_fails_retriably_is_re_resolved() {
let heavy = Proxy::new(Hosts::naming_itself().failing_commands_with(2100));
let control = Proxy::new(Hosts::listing(&heavy.host()));
let client = Client::with_token(&control.url(), "secret-token")
.with_retries(RetryPolicy::none())
.with_proxy_discovery(true)
.with_hosts_retry_after(Duration::ZERO);
for _ in 0..3 {
let _ = client.write_table("//tmp/t", b"");
}
let hosts_lookups = control
.requests()
.iter()
.filter(|l| l.starts_with("GET /hosts"))
.count();
assert!(
hosts_lookups >= 2,
"a retriable failure was treated as settled: {:?}",
control.requests()
);
}
#[test]
fn the_token_reaches_a_validated_discovered_heavy_proxy() {
let heavy = Proxy::new(Hosts::naming_itself());
let control = Proxy::new(Hosts::listing(&heavy.host()));
Client::with_token(&control.url(), "secret-token")
.with_retries(RetryPolicy::none())
.with_proxy_discovery(true)
.write_table("//tmp/t", b"")
.expect("writes");
let head = heavy
.heads()
.into_iter()
.find(|h| h.starts_with("PUT /api/v4/write_table"))
.expect("the upload reached the heavy proxy");
assert!(
head.to_lowercase()
.contains("authorization: oauth secret-token"),
"the upload reached the discovered host without its token:\n{head}"
);
}
#[test]
fn an_at_smuggled_host_is_refused_so_the_token_is_not_sent_to_it() {
let evil = Proxy::new(Hosts::naming_itself());
let control = Proxy::new(Hosts::listing(&format!(
"{}@{}",
"127.0.0.1:1",
evil.host()
)));
Client::with_token(&control.url(), "secret-token")
.with_retries(RetryPolicy::none())
.with_proxy_discovery(true)
.with_hosts_retry_after(Duration::ZERO)
.write_table("//tmp/t", b"")
.expect("falls back to the configured address");
assert!(
evil.requests().is_empty(),
"the token-bearing upload reached the @-smuggled host: {:?}",
evil.requests()
);
assert!(
control
.requests()
.iter()
.any(|l| l.contains("/api/v4/write_table")),
"the upload did not fall back to the configured address: {:?}",
control.requests()
);
}
#[test]
fn a_plain_http_discovered_host_is_refused_from_an_https_client() {
let evil = Proxy::new(Hosts::naming_itself());
let control = Proxy::new(Hosts::listing(&format!("http://{}", evil.host())));
let client = Client::with_token(&control.url(), "secret-token")
.with_retries(RetryPolicy::none())
.with_proxy_discovery(true)
.with_hosts_retry_after(Duration::ZERO);
let _ = client.write_table("//tmp/t", b"");
assert!(
evil.requests().is_empty(),
"an https client was downgraded onto an http heavy proxy: {:?}",
evil.requests()
);
}
enum Hosts {
Listing(String),
NamingItself,
NamingItselfFailing(i64),
RedirectCrossOrigin,
RedirectSameOriginTo(String),
}
impl Hosts {
fn naming_itself() -> Self {
Self::NamingItself
}
fn listing(host: &str) -> Self {
Self::Listing(host.to_owned())
}
fn failing_commands_with(self, code: i64) -> Self {
match self {
Self::NamingItself => Self::NamingItselfFailing(code),
other => other,
}
}
fn answer(&self, head: &str, me: &str) -> Vec<u8> {
let hosts = head.starts_with("GET /hosts");
match self {
Self::Listing(host) if hosts => ok_body(format!(r#"["{host}"]"#).as_bytes()),
Self::NamingItself if hosts => ok_body(format!(r#"["{me}"]"#).as_bytes()),
Self::NamingItselfFailing(_) if hosts => ok_body(format!(r#"["{me}"]"#).as_bytes()),
Self::NamingItselfFailing(code) => cluster_error(*code),
Self::RedirectCrossOrigin if hosts => {
redirect(307, "http://127.0.0.1:1/hosts")
}
Self::RedirectSameOriginTo(list) if head.starts_with("GET /hosts ") => {
redirect(307, &format!("http://{me}/hosts/"))
}
Self::RedirectSameOriginTo(list) if head.starts_with("GET /hosts/") => {
ok_body(list.as_bytes())
}
_ => ok_body(br#"{"value"=%true}"#),
}
}
}
struct Proxy {
address: std::net::SocketAddr,
seen: Arc<Mutex<Vec<String>>>,
}
impl Proxy {
fn new(serving: Hosts) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("binds");
let address = listener.local_addr().expect("has an address");
let seen = Arc::new(Mutex::new(Vec::new()));
let served = Arc::clone(&seen);
let serving = Arc::new(serving);
let me = address.to_string();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { return };
let serving = Arc::clone(&serving);
let seen = Arc::clone(&served);
let me = me.clone();
std::thread::spawn(move || serve(stream, &serving, &me, &seen));
}
});
Self { address, seen }
}
fn url(&self) -> String {
format!("http://{}", self.address)
}
fn host(&self) -> String {
self.address.to_string()
}
fn requests(&self) -> Vec<String> {
self.seen
.lock()
.expect("nothing panicked holding it")
.iter()
.map(|head| head.lines().next().unwrap_or_default().to_owned())
.collect()
}
fn heads(&self) -> Vec<String> {
self.seen
.lock()
.expect("nothing panicked holding it")
.clone()
}
}
fn serve(
mut stream: std::net::TcpStream,
serving: &Hosts,
me: &str,
seen: &Arc<Mutex<Vec<String>>>,
) {
let mut reader = BufReader::new(stream.try_clone().expect("clones"));
loop {
let mut head = String::new();
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => return,
Ok(_) if line == "\r\n" => break,
Ok(_) => head.push_str(&line),
Err(_) => return,
}
}
if head.is_empty() {
return;
}
if let Some(length) = content_length(&head) {
let mut body = vec![0_u8; length];
if reader.read_exact(&mut body).is_err() {
return;
}
} else if head.to_lowercase().contains("transfer-encoding: chunked") {
drain_chunked(&mut reader);
}
let answer = serving.answer(&head, me);
seen.lock().expect("nothing panicked holding it").push(head);
if stream.write_all(&answer).is_err() {
return;
}
stream.flush().ok();
}
}
fn ok_body(body: &[u8]) -> Vec<u8> {
let mut reply = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/x-yt-yson-text\r\n\r\n",
body.len()
)
.into_bytes();
reply.extend_from_slice(body);
reply
}
fn redirect(status: u16, location: &str) -> Vec<u8> {
format!(
"HTTP/1.1 {status} Temporary Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\n\r\n"
)
.into_bytes()
}
fn cluster_error(code: i64) -> Vec<u8> {
let document = format!(r#"{{"code":{code},"message":"stub failure {code}"}}"#);
format!("HTTP/1.1 200 OK\r\nX-YT-Error: {document}\r\nContent-Length: 0\r\n\r\n").into_bytes()
}
fn content_length(head: &str) -> Option<usize> {
head.lines()
.find(|line| line.to_lowercase().starts_with("content-length:"))
.and_then(|line| line.split_once(':'))
.and_then(|(_, value)| value.trim().parse().ok())
}
fn drain_chunked(reader: &mut BufReader<std::net::TcpStream>) {
loop {
let mut header = String::new();
if reader.read_line(&mut header).is_err() {
return;
}
let size = usize::from_str_radix(header.trim(), 16).unwrap_or(0);
let mut chunk = vec![0_u8; size + 2];
if reader.read_exact(&mut chunk).is_err() || size == 0 {
return;
}
}
}