Add YAML-configured ACLs with Basic and IP authorization

This commit is contained in:
2026-10-28 13:37:03 +00:00
parent d34b3e10f7
commit ad168aae63
10 changed files with 1366 additions and 76 deletions
+185 -22
View File
@@ -1,7 +1,16 @@
use std::{convert::Infallible, net::SocketAddr, time::Instant};
use std::{
convert::Infallible,
net::{IpAddr, SocketAddr},
sync::Arc,
time::Instant,
};
use bytes::Bytes;
use http::{HeaderValue, Method, Request, Response, StatusCode, Uri, header::HOST, uri::Authority};
use http::{
HeaderValue, Method, Request, Response, StatusCode, Uri,
header::{HOST, PROXY_AUTHENTICATE},
uri::Authority,
};
use http_body_util::{BodyExt, Empty, Full, combinators::UnsyncBoxBody};
use hyper::{body::Incoming, server::conn::http1, service::service_fn};
use hyper_util::{
@@ -14,11 +23,12 @@ use tokio::{
task::JoinSet,
time::timeout,
};
use tracing::{Instrument, Span, debug, error, field, info, info_span};
use tracing::{Instrument, Span, debug, error, field, info, info_span, warn};
use tracing_opentelemetry::OpenTelemetrySpanExt;
use crate::{
BoxError,
acl::{Acl, Decision},
config::Config,
metrics::Metrics,
telemetry::{HeaderExtractor, HeaderInjector, remove_hop_by_hop_headers},
@@ -31,6 +41,7 @@ type HttpClient = Client<HttpConnector, Incoming>;
#[derive(Clone)]
struct ProxyState {
client: HttpClient,
acl: Arc<Acl>,
metrics: Metrics,
connect_timeout: std::time::Duration,
upstream_timeout: std::time::Duration,
@@ -44,6 +55,7 @@ impl ProxyState {
Self {
client: Client::builder(TokioExecutor::new()).build(connector),
acl: config.acl.clone(),
metrics: Metrics::new(global::meter("hermes")),
connect_timeout: config.connect_timeout,
upstream_timeout: config.upstream_timeout,
@@ -92,9 +104,10 @@ pub async fn run(config: Config) -> Result<(), BoxError> {
}
async fn serve_connection(stream: TcpStream, peer: SocketAddr, state: ProxyState) {
let peer_ip = peer.ip();
let service = service_fn(move |request| {
let state = state.clone();
async move { Ok::<_, Infallible>(handle_request(request, state).await) }
async move { Ok::<_, Infallible>(handle_request(request, peer_ip, state).await) }
});
let connection = http1::Builder::new()
@@ -110,15 +123,19 @@ async fn serve_connection(stream: TcpStream, peer: SocketAddr, state: ProxyState
}
}
async fn handle_request(request: Request<Incoming>, state: ProxyState) -> ProxyResponse {
async fn handle_request(
request: Request<Incoming>,
peer: IpAddr,
state: ProxyState,
) -> ProxyResponse {
if request.method() == Method::CONNECT {
handle_connect(request, state).await
handle_connect(request, peer, state).await
} else {
handle_http(request, state).await
handle_http(request, peer, state).await
}
}
async fn handle_http(request: Request<Incoming>, state: ProxyState) -> ProxyResponse {
async fn handle_http(request: Request<Incoming>, peer: IpAddr, state: ProxyState) -> ProxyResponse {
let method = request.method().to_string();
let uri = request.uri().clone();
let parent = global::get_text_map_propagator(|propagator| {
@@ -134,6 +151,9 @@ async fn handle_http(request: Request<Incoming>, state: ProxyState) -> ProxyResp
server.address = %uri.authority().map(Authority::host).unwrap_or_default(),
http.response.status_code = field::Empty,
error.type = field::Empty,
acl.decision = field::Empty,
acl.reason = field::Empty,
acl.rule = field::Empty,
otel.status_code = field::Empty,
);
let _ = span.set_parent(parent);
@@ -141,13 +161,73 @@ async fn handle_http(request: Request<Incoming>, state: ProxyState) -> ProxyResp
async move {
let _active = state.metrics.request_started();
let started = Instant::now();
let result = forward_http(request, &state).await;
let result = match authorize_request(&request, peer, &state).await {
Ok(Some(response)) => Ok(response),
Ok(None) => forward_http(request, &state).await,
Err(error) => Err(error),
};
finish_request(result, &method, started, &state.metrics)
}
.instrument(span)
.await
}
async fn authorize_request(
request: &Request<Incoming>,
peer: IpAddr,
state: &ProxyState,
) -> Result<Option<ProxyResponse>, ProxyError> {
let outcome = state
.acl
.authorize(peer, request.headers())
.await
.map_err(|error| {
ProxyError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"acl_internal",
"acl",
error.to_string(),
)
})?;
state
.metrics
.acl_decision(outcome.decision.as_str(), outcome.reason);
Span::current().record("acl.decision", outcome.decision.as_str());
Span::current().record("acl.reason", outcome.reason);
if let Some(rule) = &outcome.rule {
Span::current().record("acl.rule", rule);
}
match outcome.decision {
Decision::Allow => {
debug!(
acl.reason = outcome.reason,
acl.rule = outcome.rule.as_deref().unwrap_or_default(),
"ACL allowed request"
);
Ok(None)
}
Decision::Deny => {
warn!(
acl.reason = outcome.reason,
acl.rule = outcome.rule.as_deref().unwrap_or_default(),
"ACL denied request"
);
Ok(Some(error_response(StatusCode::FORBIDDEN)))
}
Decision::Challenge => {
info!(acl.reason = outcome.reason, "ACL challenged request");
let mut response = error_response(StatusCode::PROXY_AUTHENTICATION_REQUIRED);
response.headers_mut().insert(
PROXY_AUTHENTICATE,
HeaderValue::from_static("Basic realm=\"hermes\", charset=\"UTF-8\""),
);
Ok(Some(response))
}
}
}
async fn forward_http(
mut request: Request<Incoming>,
state: &ProxyState,
@@ -238,7 +318,11 @@ async fn send_upstream(
.await
}
async fn handle_connect(request: Request<Incoming>, state: ProxyState) -> ProxyResponse {
async fn handle_connect(
request: Request<Incoming>,
peer: IpAddr,
state: ProxyState,
) -> ProxyResponse {
let authority = request.uri().authority().cloned();
let parent = global::get_text_map_propagator(|propagator| {
propagator.extract(&HeaderExtractor(request.headers()))
@@ -254,6 +338,9 @@ async fn handle_connect(request: Request<Incoming>, state: ProxyState) -> ProxyR
error.type = field::Empty,
tunnel.bytes.client_to_upstream = field::Empty,
tunnel.bytes.upstream_to_client = field::Empty,
acl.decision = field::Empty,
acl.reason = field::Empty,
acl.rule = field::Empty,
otel.status_code = field::Empty,
);
let _ = span.set_parent(parent);
@@ -261,7 +348,11 @@ async fn handle_connect(request: Request<Incoming>, state: ProxyState) -> ProxyR
async move {
let _active = state.metrics.request_started();
let started = Instant::now();
let result = prepare_connect(request, &state).await;
let result = match authorize_request(&request, peer, &state).await {
Ok(Some(response)) => Ok(response),
Ok(None) => prepare_connect(request, &state).await,
Err(error) => Err(error),
};
finish_request(result, "CONNECT", started, &state.metrics)
}
.instrument(span)
@@ -514,10 +605,13 @@ impl ProxyError {
#[cfg(test)]
mod tests {
use std::{
collections::BTreeMap,
sync::{Arc, Mutex},
time::Duration,
};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
use http::header::PROXY_AUTHORIZATION;
use hyper::client::conn::http1 as client_http1;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
@@ -525,6 +619,7 @@ mod tests {
};
use super::*;
use crate::config::{AclConfig, Action, RuleConfig, UserConfig};
#[test]
fn validates_absolute_http_targets() {
@@ -583,13 +678,13 @@ mod tests {
.unwrap();
});
let (proxy_addr, proxy_task) = spawn_proxy().await;
let (proxy_addr, proxy_task) = spawn_proxy_with_acl(basic_acl()).await;
let (mut sender, connection_task) = connect_client(proxy_addr).await;
let request = Request::builder()
.method(Method::GET)
.uri(format!("http://{upstream_addr}/hello?q=1"))
.header(HOST, "wrong.invalid")
.header("proxy-authorization", "must-not-be-forwarded")
.header(PROXY_AUTHORIZATION, basic_authorization())
.body(Full::new(Bytes::new()))
.unwrap();
@@ -622,7 +717,7 @@ mod tests {
stream.shutdown().await.unwrap();
});
let (proxy_addr, proxy_task) = spawn_proxy().await;
let (proxy_addr, proxy_task) = spawn_proxy_with_acl(localhost_acl()).await;
let (mut sender, connection_task) = connect_client(proxy_addr).await;
let request = Request::builder()
.method(Method::CONNECT)
@@ -645,18 +740,86 @@ mod tests {
connection_task.abort();
}
fn test_state() -> ProxyState {
ProxyState::new(&Config {
listen_addr: "127.0.0.1:0".parse().unwrap(),
connect_timeout: Duration::from_secs(2),
upstream_timeout: Duration::from_secs(2),
})
#[tokio::test]
async fn challenges_unauthenticated_http_and_connect_requests() {
let (proxy_addr, proxy_task) = spawn_proxy_with_acl(basic_acl()).await;
let (mut sender, connection_task) = connect_client(proxy_addr).await;
for request in [
Request::builder()
.method(Method::GET)
.uri("http://192.0.2.1/")
.body(Full::new(Bytes::new()))
.unwrap(),
Request::builder()
.method(Method::CONNECT)
.uri("192.0.2.1:443")
.body(Full::new(Bytes::new()))
.unwrap(),
] {
let response = sender.send_request(request).await.unwrap();
assert_eq!(response.status(), StatusCode::PROXY_AUTHENTICATION_REQUIRED);
assert_eq!(
response.headers()[PROXY_AUTHENTICATE],
"Basic realm=\"hermes\", charset=\"UTF-8\""
);
response.into_body().collect().await.unwrap();
}
proxy_task.abort();
connection_task.abort();
}
async fn spawn_proxy() -> (SocketAddr, JoinHandle<()>) {
fn basic_authorization() -> String {
format!(
"Basic {}",
BASE64_STANDARD.encode("alice:correct horse battery staple")
)
}
fn basic_acl() -> Acl {
Acl::from_config(AclConfig {
default_action: Action::Deny,
users: BTreeMap::from([(
"alice".to_owned(),
UserConfig {
password_hash: crate::acl::hash_password(b"correct horse battery staple")
.unwrap(),
},
)]),
rules: vec![RuleConfig {
name: "alice".to_owned(),
action: Action::Allow,
users: vec!["alice".to_owned()],
networks: vec![],
}],
})
.unwrap()
}
fn localhost_acl() -> Acl {
Acl::from_config(AclConfig {
default_action: Action::Deny,
users: BTreeMap::new(),
rules: vec![RuleConfig {
name: "localhost".to_owned(),
action: Action::Allow,
users: vec![],
networks: vec!["127.0.0.1/32".to_owned(), "::1/128".to_owned()],
}],
})
.unwrap()
}
async fn spawn_proxy_with_acl(acl: Acl) -> (SocketAddr, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let state = test_state();
let state = ProxyState::new(&Config {
listen_addr: address,
connect_timeout: Duration::from_secs(2),
upstream_timeout: Duration::from_secs(2),
acl: Arc::new(acl),
});
let task = tokio::spawn(async move {
while let Ok((stream, peer)) = listener.accept().await {
let state = state.clone();