Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,5 @@ serde_yaml = "0.9.34"
tokio = { version = "1.43.0", features = ["full"] }

[dev-dependencies]
http = "1.2.0"
reqwest = { version = "0.12.12", features = ["rustls-tls"] }
wiremock = "0.6"
8 changes: 4 additions & 4 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,23 @@ use tokio::time::timeout;
type HttpClient = hyper_util::client::legacy::Client<HttpConnector, Body>;

#[derive(Debug)]
pub struct Client {
pub(crate) struct Client {
client: HttpClient,
timeout: Duration,
}

impl Client {
pub fn new(timeout: Option<u64>) -> Client {
pub(crate) fn new(timeout_ms: Option<u64>) -> Client {
let client: HttpClient =
hyper_util::client::legacy::Client::<(), ()>::builder(TokioExecutor::new())
.build(HttpConnector::new());
Client {
client,
timeout: Duration::from_millis(timeout.unwrap_or(60_000)),
timeout: Duration::from_millis(timeout_ms.unwrap_or(60_000)),
}
}

pub async fn make_request(&self, req: Request<Body>) -> Response<Body> {
pub(crate) async fn make_request(&self, req: Request<Body>) -> Response<Body> {
match timeout(self.timeout, self.client.request(req)).await {
Ok(result) => match result {
Ok(response) => response.into_response(),
Expand Down
105 changes: 44 additions & 61 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use hyper::http::{
HeaderName,
};
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use tokio::signal;
#[cfg(unix)]
use tokio::signal::unix::SignalKind;
Expand All @@ -54,25 +54,25 @@ const HOP_BY_HOP_HEADERS: [HeaderName; 9] = [
header::PROXY_AUTHENTICATE,
];

#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[derive(Debug, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
struct TlsConfig {
cert_path: String,
key_path: String,
}

#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[derive(Debug, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
struct Config {
listen: SocketAddr,
tls: TlsConfig,
timeout: Option<u64>,
timeout_ms: Option<u64>,
backends: Vec<Backend>,
}

#[derive(Debug, Eq, PartialEq, Serialize, Deserialize, Clone)]
#[derive(Debug, Eq, PartialEq, Deserialize, Clone)]
#[serde(tag = "backend_type", rename_all = "lowercase", deny_unknown_fields)]
pub enum Backend {
pub(crate) enum Backend {
Single {
name: String,
location: String,
Expand Down Expand Up @@ -115,12 +115,12 @@ impl ProxyConfig {
}

#[derive(Debug)]
pub struct BackendState {
pub(crate) struct BackendState {
rr_count: AtomicUsize,
}

#[derive(Debug)]
pub struct RoutingState {
pub(crate) struct RoutingState {
backends: HashMap<String, BackendState>, // keyed by name, LoadBalanced backends only
}

Expand Down Expand Up @@ -149,7 +149,7 @@ struct ProxyState {
}

#[derive(Debug, Clone)]
pub struct ResponseContext {
pub(crate) struct ResponseContext {
backend_location: String,
}

Expand Down Expand Up @@ -246,13 +246,13 @@ fn get_host(req: &Request<Body>) -> Option<String> {
Some(host)
}

fn error_response(
mut response: Response<Body>,
status: StatusCode,
message: String,
) -> Response<Body> {
*response.body_mut() = Body::from(message);
fn error_response(status: StatusCode, message: &str) -> Response<Body> {
let mut response = Response::new(Body::from(message.to_owned()));
*response.status_mut() = status;
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
response
}

Expand All @@ -264,8 +264,6 @@ async fn proxy_handler(
proxy_config,
routing_state,
} = state;
let mut response = Response::new(Body::empty());

debug!(
"Request version: {:?} method: {} uri: {} headers: {:?}",
req.version(),
Expand All @@ -279,9 +277,8 @@ async fn proxy_handler(
Version::HTTP_10 | Version::HTTP_11 | Version::HTTP_2 => {}
_ => {
return Ok(error_response(
response,
StatusCode::HTTP_VERSION_NOT_SUPPORTED,
format!("Unsupported HTTP version: {:?}", req.version()),
&format!("Unsupported HTTP version: {:?}", req.version()),
))
}
}
Expand All @@ -296,36 +293,34 @@ async fn proxy_handler(
no_proxy, host_authority
);

match (req.method(), req.uri().path(), no_proxy, host_authority) {
let response = match (req.method(), req.uri().path(), no_proxy, host_authority) {
// Proxy internal endpoints
(&Method::GET, "/status", true, _) => {
*response.body_mut() = Body::from("The proxy is running");
}
(&Method::GET, "/status", true, _) => Response::new(Body::from("The proxy is running")),
(&Method::GET, "/metrics", true, _) => match encode_metrics() {
Ok(encoded_metrics) => {
*response.body_mut() = Body::from(encoded_metrics);
let mut response = Response::new(Body::from(encoded_metrics));
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
response
}
Err(e) => {
warn!("Error encoding metrics: {e}");
*response.body_mut() = Body::from(format!("Error encoding metrics: {e}"));
*response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Error encoding metrics: {e}"),
)
}
},

// x-no-proxy request to an unknown internal path
(_, _, true, _) => {
*response.status_mut() = StatusCode::NOT_FOUND;
}
(_, _, true, _) => error_response(StatusCode::NOT_FOUND, ""),

// A non internal request, but the host header has not been defined
(_, _, false, None) => {
debug!("Host header not defined");
*response.body_mut() = Body::from("Host header not defined");
*response.status_mut() = StatusCode::NOT_FOUND;
error_response(StatusCode::NOT_FOUND, "Host header not defined")
}

// Proxy the request
Expand All @@ -338,13 +333,9 @@ async fn proxy_handler(
);

match backend_location {
None => {
*response.status_mut() = StatusCode::NOT_FOUND;
}
None => error_response(StatusCode::NOT_FOUND, ""),
Some(backend_location) => {
// Proxy to backend

// Scheme currently hardcoded to http (given this is a TLS terminating proxy)
// Backend connections are plain HTTP — TLS is terminated at the proxy
let scheme = "http";

// Default to "/" if the URI has no path component
Expand All @@ -356,38 +347,35 @@ async fn proxy_handler(

let uri = match Uri::builder()
.scheme(scheme)
.authority(backend_location.clone())
.authority(backend_location.as_str())
.path_and_query(path_and_query)
.build()
{
Ok(uri) => uri,
Err(e) => {
warn!("Failed to build backend URI: {e}");
*response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
return Ok(response);
return Ok(error_response(StatusCode::INTERNAL_SERVER_ERROR, ""));
}
};

// Simply take the existing request and mutate the uri and headers
*req.uri_mut() = uri.clone();
debug!("Proxying request to: {}", uri);
*req.uri_mut() = uri;
adjust_backend_request_headers(&mut req, &host);

// If the backend scheme is http, adjust the original request HTTP version to 1
// (It seems that the HTTP2 implementation requires TLS)
if scheme == "http" {
*req.version_mut() = Version::HTTP_11;
}
response = proxy_config.client.make_request(req).await;
// Downgrade to HTTP/1.1 for backend connections
*req.version_mut() = Version::HTTP_11;
let mut response = proxy_config.client.make_request(req).await;
adjust_backend_response_headers(&mut response);
debug!(
"Proxied response from: {} | Status: {} | Response headers: {:?}",
uri,
"Proxied response | Status: {} | Headers: {:?}",
response.status(),
response.headers()
);
response
.extensions_mut()
.insert(ResponseContext { backend_location });
response
}
}
}
Expand Down Expand Up @@ -428,17 +416,12 @@ pub async fn run_server(config_path: String) -> Result<()> {

let listen_address = config.listen;

let client = client::Client::new(config.timeout);
let client = client::Client::new(config.timeout_ms);

let routing_state = Arc::new(RoutingState::new(&config));

let proxy_config = Arc::new(ProxyConfig::new(config, client));

let proxy_state = ProxyState {
proxy_config: proxy_config.clone(),
routing_state,
};

let current_dir = env::current_dir().context("Unable to determine current directory")?;
let tls_config = RustlsConfig::from_pem_file(
current_dir.join(&proxy_config.config.tls.cert_path),
Expand All @@ -451,6 +434,11 @@ pub async fn run_server(config_path: String) -> Result<()> {
info!("backend: {backend}");
}

let proxy_state = ProxyState {
proxy_config,
routing_state,
};

let app = Router::new()
.route("/", any(proxy_handler))
.route("/{*wildcard}", any(proxy_handler))
Expand Down Expand Up @@ -674,12 +662,7 @@ backends:

#[tokio::test]
async fn test_error_response() {
let original_response = Response::new(Body::from("test"));
let response = error_response(
original_response,
StatusCode::BAD_REQUEST,
"test error".to_string(),
);
let response = error_response(StatusCode::BAD_REQUEST, "test error");
assert_eq!(response.status(), 400);
let body = axum::body::to_bytes(response.into_body(), 1024)
.await
Expand Down
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use anyhow::Result;
use brachyura::run_server;

#[tokio::main(worker_threads = 4)]
#[tokio::main]
async fn main() -> Result<()> {
let _ = rustls::crypto::ring::default_provider().install_default();
let config_path = String::from("./config.yaml");
Expand Down
12 changes: 6 additions & 6 deletions src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ use prometheus::{

use crate::ResponseContext;

pub static METRICS: LazyLock<Metrics> = LazyLock::new(Metrics::new);
pub(crate) static METRICS: LazyLock<Metrics> = LazyLock::new(Metrics::new);

pub struct Metrics {
pub http_request_counter: IntCounterVec,
pub http_request_duration: HistogramVec,
pub(crate) struct Metrics {
pub(crate) http_request_counter: IntCounterVec,
pub(crate) http_request_duration: HistogramVec,
}

impl Metrics {
Expand All @@ -36,15 +36,15 @@ impl Metrics {
}
}

pub fn encode_metrics() -> Result<String, Error> {
pub(crate) fn encode_metrics() -> Result<String, Error> {
let mut buffer = Vec::new();
let encoder = TextEncoder::new();
let metric_families = prometheus::gather();
encoder.encode(&metric_families, &mut buffer)?;
Ok(String::from_utf8(buffer)?)
}

pub async fn record_metrics(req: Request, next: Next) -> impl IntoResponse {
pub(crate) async fn record_metrics(req: Request, next: Next) -> impl IntoResponse {
let start = Instant::now();

let response = next.run(req).await;
Expand Down
6 changes: 4 additions & 2 deletions src/routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use super::{Backend, BackendState, RoutingState};

pub fn router(
backends_config: &[Backend],
proxy_state: Arc<RoutingState>,
routing_state: Arc<RoutingState>,
host_authority: String,
) -> Option<String> {
// Matches a given host header or authority with a backend
Expand All @@ -15,7 +15,7 @@ pub fn router(
match backend {
Backend::Single { location, .. } => Some(location.clone()),
Backend::LoadBalanced { name, locations } => {
let backend_state = proxy_state.backends.get(name)?;
let backend_state = routing_state.backends.get(name)?;
round_robin_select(locations, backend_state)
}
}
Expand All @@ -40,6 +40,8 @@ fn round_robin_select(
if backend_locations.is_empty() {
return None;
}
// Relaxed ordering is sufficient as fetch_add is an atomic read-modify-write
// so no two threads can observe the same counter value
let idx = backend_state.rr_count.fetch_add(1, Ordering::Relaxed) % backend_locations.len();
Some(backend_locations[idx].clone())
}
Expand Down
2 changes: 1 addition & 1 deletion tests/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ tls:
key_path: "tests/self-signed-cert/test.key"
cert_path: "tests/self-signed-cert/test.crt"

timeout: 500
timeout_ms: 500

backends:
- name: "test.home"
Expand Down
Loading