Add YAML-configured ACLs with Basic and IP authorization
This commit is contained in:
+185
-22
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user