feat(webdav): add WebDAV protocol gateway (#2158)

Signed-off-by: yxrxy <1532529704@qq.com>
Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: 马登山 <Cxymds@qq.com>
Co-authored-by: heihutu <30542132+heihutu@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: 安正超 <anzhengchao@gmail.com>
This commit is contained in:
yxrxy
2026-03-14 23:06:53 +08:00
committed by GitHub
parent f66a90c1b2
commit d3cff7d033
21 changed files with 2265 additions and 8 deletions
+29
View File
@@ -182,6 +182,35 @@ pub fn is_operation_supported(protocol: super::session::Protocol, action: &S3Act
S3Action::GetObjectAcl => false,
S3Action::PutObjectAcl => false,
},
super::session::Protocol::WebDav => match action {
// Bucket operations
S3Action::CreateBucket => true, // MKCOL at root level
S3Action::DeleteBucket => true, // DELETE at root level
S3Action::ListBucket => true, // PROPFIND
S3Action::ListBuckets => true, // PROPFIND at root
S3Action::HeadBucket => true, // PROPFIND/HEAD
// Object operations
S3Action::GetObject => true, // GET
S3Action::PutObject => true, // PUT
S3Action::DeleteObject => true, // DELETE
S3Action::HeadObject => true, // HEAD/PROPFIND
S3Action::CopyObject => false, // COPY (not implemented yet)
// Multipart operations (not supported in WebDAV)
S3Action::CreateMultipartUpload => false,
S3Action::UploadPart => false,
S3Action::CompleteMultipartUpload => false,
S3Action::AbortMultipartUpload => false,
S3Action::ListMultipartUploads => false,
S3Action::ListParts => false,
// ACL operations (not supported in WebDAV)
S3Action::GetBucketAcl => false,
S3Action::PutBucketAcl => false,
S3Action::GetObjectAcl => false,
S3Action::PutObjectAcl => false,
},
}
}
+1
View File
@@ -21,6 +21,7 @@ use std::sync::Arc;
pub enum Protocol {
Ftps,
Swift,
WebDav,
}
/// Protocol principal representing an authenticated user
+13
View File
@@ -46,6 +46,15 @@ pub mod ftps {
pub const PASSIVE_PORTS_PART_COUNT: usize = 2;
}
/// WebDAV constants
#[cfg(feature = "webdav")]
pub mod webdav {
/// Maximum body size (5GB)
pub const MAX_BODY_SIZE: u64 = 5 * 1024 * 1024 * 1024;
/// Default request timeout in seconds
pub const REQUEST_TIMEOUT_SECS: u64 = 300;
}
/// Default configuration values
pub mod defaults {
/// Default protocol addresses
@@ -55,4 +64,8 @@ pub mod defaults {
/// Default FTPS passive port range
#[cfg(feature = "ftps")]
pub const DEFAULT_FTPS_PASSIVE_PORTS: &str = "40000-50000";
/// Default WebDAV server address
#[cfg(feature = "webdav")]
pub const DEFAULT_WEBDAV_ADDRESS: &str = "0.0.0.0:8080";
}
+6
View File
@@ -23,6 +23,9 @@ pub mod ftps;
#[cfg(feature = "swift")]
pub mod swift;
#[cfg(feature = "webdav")]
pub mod webdav;
pub use common::session::Protocol;
pub use common::{AuthorizationError, ProtocolPrincipal, S3Action, SessionContext, authorize_operation};
@@ -31,3 +34,6 @@ pub use ftps::{config::FtpsConfig, server::FtpsServer};
#[cfg(feature = "swift")]
pub use swift::handler::SwiftService;
#[cfg(feature = "webdav")]
pub use webdav::{config::WebDavConfig, server::WebDavServer};
+191
View File
@@ -0,0 +1,191 @@
# WebDAV Protocol Gateway for RustFS
WebDAV (Web Distributed Authoring and Versioning) protocol implementation for RustFS, providing HTTP-based file access compatible with native OS file managers and WebDAV clients.
## Features
- HTTP/HTTPS WebDAV server with Basic authentication
- Full CRUD operations mapping to S3 storage backend
- Directory (bucket/prefix) creation and deletion
- File upload, download, and deletion
- Property queries (PROPFIND) for metadata
- TLS support with multi-certificate SNI
- Integration with RustFS IAM for access control
### Supported WebDAV Methods
| Method | Description | S3 Operation |
|--------|-------------|--------------|
| `PROPFIND` | List directory / Get metadata | ListObjects / HeadObject |
| `MKCOL` | Create directory | CreateBucket / PutObject (prefix) |
| `PUT` | Upload file | PutObject |
| `GET` | Download file | GetObject |
| `DELETE` | Delete file/directory | DeleteObject / DeleteBucket |
| `HEAD` | Get file metadata | HeadObject |
### Not Yet Implemented
| Method | Description | Status |
|--------|-------------|--------|
| `MOVE` | Move/rename file | Returns 501 Not Implemented |
| `COPY` | Copy file | Returns 501 Not Implemented |
## Enable Feature
**WebDAV is opt-in and must be explicitly enabled.**
Build with WebDAV support:
```bash
cargo build --features webdav
```
Or enable all protocol features:
```bash
cargo build --features full
```
## Configuration
Configure WebDAV via environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `RUSTFS_WEBDAV_ENABLE` | Enable WebDAV server | `false` |
| `RUSTFS_WEBDAV_ADDRESS` | Server bind address | `0.0.0.0:8080` |
| `RUSTFS_WEBDAV_TLS_ENABLED` | Enable TLS | `true` |
| `RUSTFS_WEBDAV_CERTS_DIR` | TLS certificate directory | - |
| `RUSTFS_WEBDAV_CA_FILE` | CA file for client verification | - |
| `RUSTFS_WEBDAV_MAX_BODY_SIZE` | Max upload size (bytes) | 5GB |
| `RUSTFS_WEBDAV_REQUEST_TIMEOUT` | Request timeout (seconds) | 300 |
## Quick Start
### Start Server
```bash
RUSTFS_WEBDAV_ENABLE=true \
RUSTFS_WEBDAV_ADDRESS=0.0.0.0:8080 \
RUSTFS_WEBDAV_TLS_ENABLED=false \
RUSTFS_ACCESS_KEY=rustfsadmin \
RUSTFS_SECRET_KEY=rustfsadmin \
./target/release/rustfs /path/to/data
```
### Test with curl
```bash
# List root (buckets)
curl -u rustfsadmin:rustfsadmin -X PROPFIND http://127.0.0.1:8080/ -H "Depth: 1"
# Create bucket
curl -u rustfsadmin:rustfsadmin -X MKCOL http://127.0.0.1:8080/mybucket/
# Upload file
curl -u rustfsadmin:rustfsadmin -T file.txt http://127.0.0.1:8080/mybucket/file.txt
# Download file
curl -u rustfsadmin:rustfsadmin http://127.0.0.1:8080/mybucket/file.txt
# Create subdirectory
curl -u rustfsadmin:rustfsadmin -X MKCOL http://127.0.0.1:8080/mybucket/subdir/
# Delete file
curl -u rustfsadmin:rustfsadmin -X DELETE http://127.0.0.1:8080/mybucket/file.txt
# Delete bucket
curl -u rustfsadmin:rustfsadmin -X DELETE http://127.0.0.1:8080/mybucket/
```
## Client Configuration
### Linux (GNOME Files / Nautilus)
1. Open Files application
2. Press `Ctrl+L` to show address bar
3. Enter: `dav://rustfsadmin:rustfsadmin@127.0.0.1:8080/`
### macOS Finder
1. Open Finder
2. Press `Cmd+K` (Connect to Server)
3. Enter: `http://rustfsadmin:rustfsadmin@127.0.0.1:8080/`
### Windows Explorer
1. Open This PC
2. Click "Map network drive"
3. Enter: `http://127.0.0.1:8080/`
4. Enter credentials when prompted
### VSCode (WebDAV Extension)
Install `jonpfote.webdav` extension and create `.code-workspace`:
```json
{
"folders": [
{
"uri": "webdav://rustfs-local",
"name": "RustFS WebDAV"
}
],
"settings": {
"jonpfote.webdav-folders": {
"rustfs-local": {
"host": "127.0.0.1:8080",
"ssl": false,
"authtype": "basic",
"username": "rustfsadmin",
"password": "rustfsadmin"
}
}
}
}
```
## Architecture
```
WebDAV Client (curl, Finder, Explorer, VSCode)
▼ HTTP/HTTPS
┌───────────────────┐
│ WebDavServer │ ← Hyper HTTP server + Basic Auth
└─────────┬─────────┘
┌─────────▼─────────┐
│ WebDavDriver │ ← DavFileSystem implementation
└─────────┬─────────┘
┌─────────▼─────────┐
│ StorageBackend │ ← S3 API operations
└─────────┬─────────┘
┌─────────▼─────────┐
│ ECStore │ ← Erasure coded storage
└───────────────────┘
```
### Key Components
- **config.rs** - WebDAV server configuration
- **server.rs** - Hyper HTTP server with TLS and Basic authentication
- **driver.rs** - DavFileSystem trait implementation mapping to S3
### Path Mapping
WebDAV paths are mapped to S3 buckets and objects:
```
WebDAV Path S3 Mapping
/ → List all buckets
/mybucket/ → Bucket: mybucket
/mybucket/file.txt → Bucket: mybucket, Key: file.txt
/mybucket/dir/file.txt → Bucket: mybucket, Key: dir/file.txt
```
## License
Apache License 2.0
+103
View File
@@ -0,0 +1,103 @@
// Copyright 2024 RustFS Team
//
// 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 std::fmt::Debug;
use std::net::SocketAddr;
use thiserror::Error;
/// WebDAV server initialization error
#[derive(Debug, Error)]
pub enum WebDavInitError {
#[error("failed to bind address: {0}")]
Bind(#[from] std::io::Error),
#[error("server error: {0}")]
Server(String),
#[error("invalid WebDAV configuration: {0}")]
InvalidConfig(String),
#[error("TLS error: {0}")]
Tls(String),
}
/// WebDAV server configuration
#[derive(Debug, Clone)]
pub struct WebDavConfig {
/// Server bind address
pub bind_addr: SocketAddr,
/// Whether TLS is enabled (default: true)
pub tls_enabled: bool,
/// Certificate directory path (supports multiple certificates)
pub cert_dir: Option<String>,
/// CA certificate file path for client certificate verification
pub ca_file: Option<String>,
/// Maximum request body size in bytes (default: 5GB)
pub max_body_size: u64,
/// Request timeout in seconds (default: 300)
pub request_timeout_secs: u64,
}
impl WebDavConfig {
/// Default maximum body size (5GB)
pub const DEFAULT_MAX_BODY_SIZE: u64 = 5 * 1024 * 1024 * 1024;
/// Default request timeout (300 seconds)
pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 300;
/// Validates the configuration
pub async fn validate(&self) -> Result<(), WebDavInitError> {
// Validate TLS configuration
if self.tls_enabled && self.cert_dir.is_none() {
return Err(WebDavInitError::InvalidConfig(
"TLS is enabled but certificate directory is missing".to_string(),
));
}
if let Some(path) = &self.cert_dir
&& !tokio::fs::try_exists(path).await.unwrap_or(false)
{
return Err(WebDavInitError::InvalidConfig(format!("Certificate directory not found: {}", path)));
}
// Validate CA file exists if specified
if let Some(path) = &self.ca_file
&& !tokio::fs::try_exists(path).await.unwrap_or(false)
{
return Err(WebDavInitError::InvalidConfig(format!("CA file not found: {}", path)));
}
// Validate max body size
if self.max_body_size == 0 {
return Err(WebDavInitError::InvalidConfig("max_body_size cannot be zero".to_string()));
}
// Validate request timeout
if self.request_timeout_secs == 0 {
return Err(WebDavInitError::InvalidConfig("request_timeout_secs cannot be zero".to_string()));
}
Ok(())
}
}
impl Default for WebDavConfig {
fn default() -> Self {
Self {
// Use direct construction instead of parse().unwrap() to avoid panic
bind_addr: SocketAddr::from(([0, 0, 0, 0], 8080)),
tls_enabled: true,
cert_dir: None,
ca_file: None,
max_body_size: Self::DEFAULT_MAX_BODY_SIZE,
request_timeout_secs: Self::DEFAULT_REQUEST_TIMEOUT_SECS,
}
}
}
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
// Copyright 2024 RustFS Team
//
// 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.
pub mod config;
pub mod driver;
pub mod server;
+334
View File
@@ -0,0 +1,334 @@
// Copyright 2024 RustFS Team
//
// 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 super::config::{WebDavConfig, WebDavInitError};
use super::driver::WebDavDriver;
use crate::common::client::s3::StorageBackend;
use crate::common::session::{Protocol, ProtocolPrincipal, SessionContext};
use bytes::Bytes;
use dav_server::DavHandler;
use dav_server::fakels::FakeLs;
use http_body_util::{BodyExt, Full};
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use rustls::ServerConfig;
use std::convert::Infallible;
use std::net::IpAddr;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::broadcast;
use tokio_rustls::TlsAcceptor;
use tracing::{debug, error, info, warn};
/// WebDAV server implementation
pub struct WebDavServer<S>
where
S: StorageBackend + Clone + Send + Sync + 'static + std::fmt::Debug,
{
/// Server configuration
config: WebDavConfig,
/// S3 storage backend
storage: S,
}
impl<S> WebDavServer<S>
where
S: StorageBackend + Clone + Send + Sync + 'static + std::fmt::Debug,
{
/// Create a new WebDAV server
pub async fn new(config: WebDavConfig, storage: S) -> Result<Self, WebDavInitError> {
config.validate().await?;
Ok(Self { config, storage })
}
/// Start the WebDAV server
pub async fn start(&self, mut shutdown_rx: broadcast::Receiver<()>) -> Result<(), WebDavInitError> {
info!("Initializing WebDAV server on {}", self.config.bind_addr);
let listener = TcpListener::bind(self.config.bind_addr).await?;
info!("WebDAV server listening on {}", self.config.bind_addr);
// Setup TLS if enabled
let tls_acceptor = if self.config.tls_enabled {
if let Some(cert_dir) = &self.config.cert_dir {
debug!("Enabling WebDAV TLS with certificates from: {}", cert_dir);
let cert_key_pairs = rustfs_utils::load_all_certs_from_directory(cert_dir)
.map_err(|e| WebDavInitError::Tls(format!("Failed to load certificates: {}", e)))?;
if cert_key_pairs.is_empty() {
return Err(WebDavInitError::InvalidConfig("No valid certificates found".into()));
}
let resolver = rustfs_utils::create_multi_cert_resolver(cert_key_pairs)
.map_err(|e| WebDavInitError::Tls(format!("Failed to create certificate resolver: {}", e)))?;
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let server_config = ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(Arc::new(resolver));
Some(TlsAcceptor::from(Arc::new(server_config)))
} else {
None
}
} else {
None
};
let storage = self.storage.clone();
loop {
tokio::select! {
accept_result = listener.accept() => {
match accept_result {
Ok((stream, addr)) => {
let storage = storage.clone();
let tls_acceptor = tls_acceptor.clone();
let max_body_size = self.config.max_body_size;
tokio::spawn(async move {
let source_ip: IpAddr = addr.ip();
if let Some(acceptor) = tls_acceptor {
match acceptor.accept(stream).await {
Ok(tls_stream) => {
let io = TokioIo::new(tls_stream);
if let Err(e) = Self::handle_connection_impl(io, storage, source_ip, max_body_size).await {
debug!("Connection error: {}", e);
}
}
Err(e) => {
debug!("TLS handshake failed: {}", e);
}
}
} else {
let io = TokioIo::new(stream);
if let Err(e) = Self::handle_connection_impl(io, storage, source_ip, max_body_size).await {
debug!("Connection error: {}", e);
}
}
});
}
Err(e) => {
error!("Failed to accept connection: {}", e);
}
}
}
_ = shutdown_rx.recv() => {
info!("WebDAV server received shutdown signal");
break;
}
}
}
info!("WebDAV server stopped");
Ok(())
}
/// Handle a single connection with hyper-util TokioIo wrapper
async fn handle_connection_impl<I>(
io: TokioIo<I>,
storage: S,
source_ip: IpAddr,
max_body_size: u64,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
where
I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let service = service_fn(move |req: Request<hyper::body::Incoming>| {
let storage = storage.clone();
async move { Self::handle_request(req, storage, source_ip, max_body_size).await }
});
http1::Builder::new().serve_connection(io, service).await?;
Ok(())
}
/// Handle a single WebDAV request
async fn handle_request(
req: Request<hyper::body::Incoming>,
storage: S,
source_ip: IpAddr,
max_body_size: u64,
) -> Result<Response<Full<Bytes>>, Infallible> {
// Check Content-Length against max_body_size before reading body
if let Some(content_length) = req.headers().get("content-length")
&& let Ok(length_str) = content_length.to_str()
&& let Ok(length) = length_str.parse::<u64>()
&& length > max_body_size
{
warn!("Request body too large: {} > {}", length, max_body_size);
return Ok(error_response(
StatusCode::PAYLOAD_TOO_LARGE,
&format!("Request body too large. Maximum size is {} bytes", max_body_size),
));
}
// Extract authorization header
let auth_header = req.headers().get("authorization").and_then(|h| h.to_str().ok());
// Parse Basic auth credentials
let (access_key, secret_key) = match auth_header {
Some(auth) if auth.starts_with("Basic ") => {
let encoded = &auth[6..];
match base64_decode(encoded) {
Ok(decoded) => {
let decoded_str = String::from_utf8_lossy(&decoded);
if let Some((user, pass)) = decoded_str.split_once(':') {
(user.to_string(), pass.to_string())
} else {
return Ok(unauthorized_response());
}
}
Err(_) => return Ok(unauthorized_response()),
}
}
_ => return Ok(unauthorized_response()),
};
// Authenticate user
let session_context = match Self::authenticate(&access_key, &secret_key, source_ip).await {
Ok(ctx) => ctx,
Err(_) => return Ok(unauthorized_response()),
};
// Create WebDAV driver with session context
let driver = WebDavDriver::new(storage, Arc::new(session_context));
// Build DAV handler with boxed filesystem
let dav_handler = DavHandler::builder()
.filesystem(Box::new(driver))
.locksystem(FakeLs::new())
.build_handler();
// Convert request body
let (parts, body) = req.into_parts();
let body_bytes = match body.collect().await {
Ok(collected) => collected.to_bytes(),
Err(e) => {
error!("Failed to read request body: {}", e);
return Ok(error_response(StatusCode::BAD_REQUEST, "Failed to read request body"));
}
};
// Create request for dav-server using Bytes
let dav_req = Request::from_parts(parts, dav_server::body::Body::from(body_bytes));
// Handle the request
let dav_resp = dav_handler.handle(dav_req).await;
// Convert response
let (parts, body) = dav_resp.into_parts();
let body_bytes = match body.collect().await {
Ok(collected) => collected.to_bytes(),
Err(e) => {
error!("Failed to read response body: {}", e);
return Ok(error_response(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error"));
}
};
Ok(Response::from_parts(parts, Full::new(body_bytes)))
}
/// Authenticate user against IAM system
async fn authenticate(access_key: &str, secret_key: &str, source_ip: IpAddr) -> Result<SessionContext, WebDavInitError> {
use rustfs_credentials::Credentials as S3Credentials;
use rustfs_iam::get;
// Access IAM system
let iam_sys = get().map_err(|e| {
error!("IAM system unavailable during WebDAV auth: {}", e);
WebDavInitError::Server("Internal authentication service unavailable".to_string())
})?;
let s3_creds = S3Credentials {
access_key: access_key.to_string(),
secret_key: secret_key.to_string(),
session_token: String::new(),
expiration: None,
status: String::new(),
parent_user: String::new(),
groups: None,
claims: None,
name: None,
description: None,
};
let (user_identity, is_valid) = iam_sys.check_key(&s3_creds.access_key).await.map_err(|e| {
error!("IAM check_key failed for {}: {}", access_key, e);
WebDavInitError::Server("Authentication verification failed".to_string())
})?;
if !is_valid {
warn!("WebDAV login failed: Invalid access key '{}'", access_key);
return Err(WebDavInitError::Server("Invalid credentials".to_string()));
}
let identity = user_identity.ok_or_else(|| {
error!("User identity missing despite valid key for {}", access_key);
WebDavInitError::Server("User not found".to_string())
})?;
if !identity.credentials.secret_key.eq(&s3_creds.secret_key) {
warn!("WebDAV login failed: Invalid secret key for '{}'", access_key);
return Err(WebDavInitError::Server("Invalid credentials".to_string()));
}
info!("WebDAV user '{}' authenticated successfully", access_key);
Ok(SessionContext::new(
ProtocolPrincipal::new(Arc::new(identity)),
Protocol::WebDav,
source_ip,
))
}
/// Get server configuration
pub fn config(&self) -> &WebDavConfig {
&self.config
}
/// Get storage backend
pub fn storage(&self) -> &S {
&self.storage
}
}
/// Create unauthorized response with WWW-Authenticate header
fn unauthorized_response() -> Response<Full<Bytes>> {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("WWW-Authenticate", "Basic realm=\"RustFS WebDAV\"")
.body(Full::new(Bytes::from("Unauthorized")))
.unwrap_or_else(|_| Response::new(Full::new(Bytes::from("Unauthorized"))))
}
/// Create error response
fn error_response(status: StatusCode, message: &str) -> Response<Full<Bytes>> {
Response::builder()
.status(status)
.body(Full::new(Bytes::from(message.to_string())))
.unwrap_or_else(|_| Response::new(Full::new(Bytes::from("Internal Server Error"))))
}
/// Decode base64 string
fn base64_decode(encoded: &str) -> Result<Vec<u8>, ()> {
use base64::Engine;
base64::engine::general_purpose::STANDARD.decode(encoded).map_err(|_| ())
}