diff --git a/Cargo.lock b/Cargo.lock index 35d81388..5492058c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -985,6 +985,7 @@ dependencies = [ name = "dragonfly-client-core" version = "0.1.112" dependencies = [ + "headers 0.4.0", "hyper 1.4.1", "hyper-util", "libloading", @@ -1043,11 +1044,13 @@ name = "dragonfly-client-util" version = "0.1.112" dependencies = [ "base16ct", + "base64 0.22.1", "blake3", "crc32fast", "dragonfly-api", "dragonfly-client-core", "hex", + "http 1.1.0", "http-range-header", "hyper 1.4.1", "openssl", @@ -1405,13 +1408,28 @@ checksum = "06683b93020a07e3dbcf5f8c0f6d40080d725bea7936fc01ad345c01b97dc270" dependencies = [ "base64 0.21.7", "bytes", - "headers-core", + "headers-core 0.2.0", "http 0.2.11", "httpdate", "mime", "sha1", ] +[[package]] +name = "headers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9" +dependencies = [ + "base64 0.21.7", + "bytes", + "headers-core 0.3.0", + "http 1.1.0", + "httpdate", + "mime", + "sha1", +] + [[package]] name = "headers-core" version = "0.2.0" @@ -1421,6 +1439,15 @@ dependencies = [ "http 0.2.11", ] +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.1.0", +] + [[package]] name = "heck" version = "0.4.1" @@ -4724,7 +4751,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "headers", + "headers 0.3.9", "http 0.2.11", "hyper 0.14.28", "log", diff --git a/dragonfly-client-config/src/dfdaemon.rs b/dragonfly-client-config/src/dfdaemon.rs index 5810a0de..696b9113 100644 --- a/dragonfly-client-config/src/dfdaemon.rs +++ b/dragonfly-client-config/src/dfdaemon.rs @@ -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; @@ -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")] @@ -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, + + /// 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, } /// ProxyServer implements Default. @@ -917,6 +946,7 @@ impl Default for ProxyServer { port: default_proxy_server_port(), ca_cert: None, ca_key: None, + basic_auth: None, } } } diff --git a/dragonfly-client-core/Cargo.toml b/dragonfly-client-core/Cargo.toml index 44236f7c..f843860f 100644 --- a/dragonfly-client-core/Cargo.toml +++ b/dragonfly-client-core/Cargo.toml @@ -19,4 +19,5 @@ hyper.workspace = true hyper-util.workspace = true opendal.workspace = true url.workspace = true +headers.workspace = true libloading = "0.8.5" diff --git a/dragonfly-client-core/src/error/mod.rs b/dragonfly-client-core/src/error/mod.rs index dfd16374..bb6f6c73 100644 --- a/dragonfly-client-core/src/error/mod.rs +++ b/dragonfly-client-core/src/error/mod.rs @@ -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), @@ -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), diff --git a/dragonfly-client-util/Cargo.toml b/dragonfly-client-util/Cargo.toml index 3508a505..ea186d37 100644 --- a/dragonfly-client-util/Cargo.toml +++ b/dragonfly-client-util/Cargo.toml @@ -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 @@ -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 diff --git a/dragonfly-client-util/src/http/basic_auth.rs b/dragonfly-client-util/src/http/basic_auth.rs new file mode 100644 index 00000000..9c71db00 --- /dev/null +++ b/dragonfly-client-util/src/http/basic_auth.rs @@ -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(()) + } +} diff --git a/dragonfly-client-util/src/http/mod.rs b/dragonfly-client-util/src/http/mod.rs index 61cdc0b9..4530909a 100644 --- a/dragonfly-client-util/src/http/mod.rs +++ b/dragonfly-client-util/src/http/mod.rs @@ -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) -> HashMap { diff --git a/dragonfly-client/src/proxy/mod.rs b/dragonfly-client/src/proxy/mod.rs index 0f6b0e85..cf38ad06 100644 --- a/dragonfly-client/src/proxy/mod.rs +++ b/dragonfly-client/src/proxy/mod.rs @@ -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}; @@ -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) @@ -315,6 +315,21 @@ pub async fn http_handler( ) -> ClientResult { 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) = @@ -425,9 +440,6 @@ async fn upgraded_tunnel( registry_cert: Arc>>>, server_ca_cert: Arc>, ) -> 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. @@ -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()) @@ -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()) @@ -827,7 +854,7 @@ async fn proxy_http(request: Request) -> ClientResult