Skip to content

Commit

Permalink
feat: add basic auth for HTTP proxy (#785)
Browse files Browse the repository at this point in the history
Signed-off-by: Gaius <[email protected]>
  • Loading branch information
gaius-qi authored Oct 18, 2024
1 parent f73239f commit e396706
Show file tree
Hide file tree
Showing 8 changed files with 189 additions and 11 deletions.
31 changes: 29 additions & 2 deletions Cargo.lock

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

32 changes: 31 additions & 1 deletion dragonfly-client-config/src/dfdaemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ use dragonfly_client_core::{
error::{ErrorType, OrErr},
Result,
};
use dragonfly_client_util::tls::{generate_ca_cert_from_pem, generate_cert_from_pem};
use dragonfly_client_util::{
http::basic_auth,
tls::{generate_ca_cert_from_pem, generate_cert_from_pem},
};
use local_ip_address::{local_ip, local_ipv6};
use rcgen::Certificate;
use regex::Regex;
Expand Down Expand Up @@ -871,6 +874,26 @@ impl Default for GC {
}
}

/// BasicAuth is the basic auth configuration for HTTP proxy in dfdaemon.
#[derive(Default, Debug, Clone, Validate, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct BasicAuth {
/// username is the username of the basic auth.
#[validate(length(min = 1, max = 20))]
pub username: String,

/// passwork is the passwork of the basic auth.
#[validate(length(min = 1, max = 20))]
pub password: String,
}

impl BasicAuth {
/// credentials loads the credentials.
pub fn credentials(&self) -> basic_auth::Credentials {
basic_auth::Credentials::new(&self.username, &self.password)
}
}

/// ProxyServer is the proxy server configuration for dfdaemon.
#[derive(Debug, Clone, Validate, Deserialize)]
#[serde(default, rename_all = "camelCase")]
Expand Down Expand Up @@ -907,6 +930,12 @@ pub struct ProxyServer {
/// and key, and signs the server cert with the root CA cert. When client requests via the proxy,
/// the proxy can intercept the request by the server cert.
pub ca_key: Option<PathBuf>,

/// basic_auth is the basic auth configuration for HTTP proxy in dfdaemon. If basic_auth is not
/// empty, the proxy will use the basic auth to authenticate the client by Authorization
/// header. The value of the Authorization header is "Basic base64(username:password)", refer
/// to https://en.wikipedia.org/wiki/Basic_access_authentication.
pub basic_auth: Option<BasicAuth>,
}

/// ProxyServer implements Default.
Expand All @@ -917,6 +946,7 @@ impl Default for ProxyServer {
port: default_proxy_server_port(),
ca_cert: None,
ca_key: None,
basic_auth: None,
}
}
}
Expand Down
1 change: 1 addition & 0 deletions dragonfly-client-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ hyper.workspace = true
hyper-util.workspace = true
opendal.workspace = true
url.workspace = true
headers.workspace = true
libloading = "0.8.5"
8 changes: 8 additions & 0 deletions dragonfly-client-core/src/error/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ pub enum DFError {
#[error{"RangeUnsatisfiable: Failed to parse range fallback error, please file an issue"}]
EmptyHTTPRangeError,

/// Unauthorized is the error for unauthorized.
#[error{"unauthorized"}]
Unauthorized,

/// TonicStatus is the error for tonic status.
#[error(transparent)]
TonicStatus(#[from] tonic::Status),
Expand All @@ -153,6 +157,10 @@ pub enum DFError {
#[error(transparent)]
TokioStreamElapsed(#[from] tokio_stream::Elapsed),

/// HeadersError is the error for headers.
#[error(transparent)]
HeadersError(#[from] headers::Error),

/// URLParseError is the error for url parse.
#[error(transparent)]
URLParseError(#[from] url::ParseError),
Expand Down
2 changes: 2 additions & 0 deletions dragonfly-client-util/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ dragonfly-api.workspace = true
reqwest.workspace = true
hyper.workspace = true
http-range-header.workspace = true
http.workspace = true
tracing.workspace = true
url.workspace = true
rcgen.workspace = true
Expand All @@ -28,6 +29,7 @@ openssl.workspace = true
blake3.workspace = true
crc32fast.workspace = true
base16ct.workspace = true
base64 = "0.22.1"

[dev-dependencies]
tempfile.workspace = true
81 changes: 81 additions & 0 deletions dragonfly-client-util/src/http/basic_auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright 2024 The Dragonfly Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

use base64::prelude::*;
use dragonfly_client_core::{
error::{ErrorType, OrErr},
Error, Result,
};
use http::header::{self, HeaderMap};
use tracing::instrument;

/// Credentials is the credentials for the basic auth.
pub struct Credentials {
/// username is the username.
pub username: String,

/// password is the password.
pub password: String,
}

/// Credentials is the basic auth.
impl Credentials {
/// new returns a new Credentials.
#[instrument(skip_all)]
pub fn new(username: &str, password: &str) -> Credentials {
Self {
username: username.to_string(),
password: password.to_string(),
}
}

/// verify verifies the basic auth with the header.
pub fn verify(&self, header: &HeaderMap) -> Result<()> {
let Some(auth_header) = header.get(header::AUTHORIZATION) else {
return Err(Error::Unauthorized);
};

if let Some((typ, payload)) = auth_header
.to_str()
.or_err(ErrorType::ParseError)?
.to_string()
.split_once(' ')
{
if typ.to_lowercase() != "basic" {
return Err(Error::Unauthorized);
};

let decoded = String::from_utf8(
BASE64_STANDARD
.decode(payload)
.or_err(ErrorType::ParseError)?,
)
.or_err(ErrorType::ParseError)?;

let Some((username, password)) = decoded.split_once(':') else {
return Err(Error::Unauthorized);
};

if username != self.username || password != self.password {
return Err(Error::Unauthorized);
}

return Ok(());
}

Ok(())
}
}
2 changes: 2 additions & 0 deletions dragonfly-client-util/src/http/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ use reqwest::header::{HeaderMap, HeaderValue};
use std::collections::HashMap;
use tracing::{error, instrument};

pub mod basic_auth;

/// reqwest_headermap_to_hashmap converts a reqwest headermap to a hashmap.
#[instrument(skip_all)]
pub fn reqwest_headermap_to_hashmap(header: &HeaderMap<HeaderValue>) -> HashMap<String, String> {
Expand Down
43 changes: 35 additions & 8 deletions dragonfly-client/src/proxy/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ use dragonfly_client_util::{
use futures_util::TryStreamExt;
use http_body_util::{combinators::BoxBody, BodyExt, Empty, StreamBody};
use hyper::body::Frame;
use hyper::client::conn::http1::Builder;
use hyper::server::conn::http1;
use hyper::client::conn::http1::Builder as ClientBuilder;
use hyper::server::conn::http1::Builder as ServerBuilder;
use hyper::service::service_fn;
use hyper::upgrade::Upgraded;
use hyper::{Method, Request};
Expand Down Expand Up @@ -170,7 +170,7 @@ impl Proxy {
let registry_cert = self.registry_cert.clone();
let server_ca_cert = self.server_ca_cert.clone();
tokio::task::spawn(async move {
if let Err(err) = http1::Builder::new()
if let Err(err) = ServerBuilder::new()
.keep_alive(true)
.preserve_header_case(true)
.title_case_headers(true)
Expand Down Expand Up @@ -315,6 +315,21 @@ pub async fn http_handler(
) -> ClientResult<Response> {
info!("handle HTTP request: {:?}", request);

// Authenticate the request with the basic auth.
if let Some(basic_auth) = config.proxy.server.basic_auth.as_ref() {
match basic_auth.credentials().verify(request.headers()) {
Ok(_) => {}
Err(ClientError::Unauthorized) => {
error!("basic auth failed");
return Ok(make_error_response(http::StatusCode::UNAUTHORIZED, None));
}
Err(err) => {
error!("verify basic auth failed: {}", err);
return Ok(make_error_response(http::StatusCode::BAD_REQUEST, None));
}
}
}

// If find the matching rule, proxy the request via the dfdaemon.
let request_uri = request.uri();
if let Some(rule) =
Expand Down Expand Up @@ -425,9 +440,6 @@ async fn upgraded_tunnel(
registry_cert: Arc<Option<Vec<CertificateDer<'static>>>>,
server_ca_cert: Arc<Option<Certificate>>,
) -> ClientResult<()> {
// Initialize the tcp stream to the remote server.
let upgraded = TokioIo::new(upgraded);

// Generate the self-signed certificate by the given host. If the ca_cert
// is not set, use the self-signed certificate. Otherwise, use the CA
// certificate to sign the self-signed certificate.
Expand All @@ -449,8 +461,9 @@ async fn upgraded_tunnel(
.with_single_cert(server_certs, server_key)
.or_err(ErrorType::TLSConfigError)?;
server_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec(), b"http/1.0".to_vec()];

let tls_acceptor = TlsAcceptor::from(Arc::new(server_config));
let tls_stream = tls_acceptor.accept(upgraded).await?;
let tls_stream = tls_acceptor.accept(TokioIo::new(upgraded)).await?;

// Serve the connection with the TLS stream.
if let Err(err) = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new())
Expand Down Expand Up @@ -490,6 +503,20 @@ pub async fn upgraded_handler(
Span::current().record("uri", request.uri().to_string().as_str());
Span::current().record("method", request.method().as_str());

// Authenticate the request with the basic auth.
if let Some(basic_auth) = config.proxy.server.basic_auth.as_ref() {
match basic_auth.credentials().verify(request.headers()) {
Ok(_) => {}
Err(ClientError::Unauthorized) => {
return Ok(make_error_response(http::StatusCode::UNAUTHORIZED, None));
}
Err(err) => {
error!("verify basic auth failed: {}", err);
return Ok(make_error_response(http::StatusCode::BAD_REQUEST, None));
}
}
}

// If the scheme is not set, set the scheme to https.
if request.uri().scheme().is_none() {
*request.uri_mut() = format!("https://{}{}", host, request.uri())
Expand Down Expand Up @@ -827,7 +854,7 @@ async fn proxy_http(request: Request<hyper::body::Incoming>) -> ClientResult<Res

let stream = TcpStream::connect((host, port)).await?;
let io = TokioIo::new(stream);
let (mut client, conn) = Builder::new()
let (mut client, conn) = ClientBuilder::new()
.preserve_header_case(true)
.title_case_headers(true)
.handshake(io)
Expand Down

0 comments on commit e396706

Please sign in to comment.