diff --git a/seaweed-volume/src/config.rs b/seaweed-volume/src/config.rs index 69ed838f6..b462a7205 100644 --- a/seaweed-volume/src/config.rs +++ b/seaweed-volume/src/config.rs @@ -224,6 +224,7 @@ pub struct VolumeServerConfig { pub metrics_ip: String, pub debug: bool, pub debug_port: u16, + pub ui_enabled: bool, pub jwt_signing_key: Vec, pub jwt_signing_expires_seconds: i64, pub jwt_read_signing_key: Vec, @@ -417,9 +418,9 @@ fn resolve_config(cli: Cli) -> VolumeServerConfig { .max .split(',') .map(|s| { - s.trim() - .parse::() - .unwrap_or_else(|_| panic!("The max specified in --max is not a valid number: {}", s)) + s.trim().parse::().unwrap_or_else(|_| { + panic!("The max specified in --max is not a valid number: {}", s) + }) }) .collect(); // Replicate single value to all folders @@ -436,7 +437,8 @@ fn resolve_config(cli: Cli) -> VolumeServerConfig { } // Parse min free spaces - let mut min_free_spaces = parse_min_free_spaces(&cli.min_free_space, &cli.min_free_space_percent); + let mut min_free_spaces = + parse_min_free_spaces(&cli.min_free_space, &cli.min_free_space_percent); if min_free_spaces.len() == 1 && folder_count > 1 { let v = min_free_spaces[0].clone(); min_free_spaces.resize(folder_count, v); @@ -450,11 +452,7 @@ fn resolve_config(cli: Cli) -> VolumeServerConfig { } // Parse disk types - let mut disk_types: Vec = cli - .disk - .split(',') - .map(|s| s.trim().to_string()) - .collect(); + let mut disk_types: Vec = cli.disk.split(',').map(|s| s.trim().to_string()).collect(); if disk_types.len() == 1 && folder_count > 1 { let v = disk_types[0].clone(); disk_types.resize(folder_count, v); @@ -527,7 +525,10 @@ fn resolve_config(cli: Cli) -> VolumeServerConfig { "leveldb" => NeedleMapKind::LevelDb, "leveldbMedium" => NeedleMapKind::LevelDbMedium, "leveldbLarge" => NeedleMapKind::LevelDbLarge, - other => panic!("Unknown index type: {}. Use memory|leveldb|leveldbMedium|leveldbLarge", other), + other => panic!( + "Unknown index type: {}. Use memory|leveldb|leveldbMedium|leveldbLarge", + other + ), }; // Parse read mode @@ -593,6 +594,7 @@ fn resolve_config(cli: Cli) -> VolumeServerConfig { metrics_ip, debug: cli.debug, debug_port: cli.debug_port, + ui_enabled: sec.jwt_signing_key.is_empty() || sec.access_ui, jwt_signing_key: sec.jwt_signing_key, jwt_signing_expires_seconds: sec.jwt_signing_expires, jwt_read_signing_key: sec.jwt_read_signing_key, @@ -601,7 +603,9 @@ fn resolve_config(cli: Cli) -> VolumeServerConfig { https_key_file: sec.https_key_file, grpc_cert_file: sec.grpc_cert_file, grpc_key_file: sec.grpc_key_file, - enable_write_queue: std::env::var("SEAWEED_WRITE_QUEUE").map(|v| v == "1" || v == "true").unwrap_or(false), + enable_write_queue: std::env::var("SEAWEED_WRITE_QUEUE") + .map(|v| v == "1" || v == "true") + .unwrap_or(false), } } @@ -616,6 +620,7 @@ pub struct SecurityConfig { pub https_key_file: String, pub grpc_cert_file: String, pub grpc_key_file: String, + pub access_ui: bool, /// IPs from [guard] white_list in security.toml pub guard_white_list: Vec, } @@ -658,6 +663,7 @@ fn parse_security_config(path: &str) -> SecurityConfig { HttpsVolume, GrpcVolume, Guard, + Access, } let mut section = Section::None; @@ -687,6 +693,10 @@ fn parse_security_config(path: &str) -> SecurityConfig { section = Section::Guard; continue; } + if trimmed == "[access]" { + section = Section::Access; + continue; + } if trimmed.starts_with('[') { section = Section::None; continue; @@ -698,7 +708,9 @@ fn parse_security_config(path: &str) -> SecurityConfig { match section { Section::JwtSigningRead => match key { "key" => cfg.jwt_read_signing_key = value.as_bytes().to_vec(), - "expires_after_seconds" => cfg.jwt_read_signing_expires = value.parse().unwrap_or(0), + "expires_after_seconds" => { + cfg.jwt_read_signing_expires = value.parse().unwrap_or(0) + } _ => {} }, Section::JwtSigning => match key { @@ -726,6 +738,10 @@ fn parse_security_config(path: &str) -> SecurityConfig { } _ => {} }, + Section::Access => match key { + "ui" => cfg.access_ui = value.parse().unwrap_or(false), + _ => {} + }, Section::None => {} } } @@ -806,4 +822,24 @@ mod tests { assert_eq!(tags[0], Vec::::new()); assert_eq!(tags[1], Vec::::new()); } + + #[test] + fn test_parse_security_config_access_ui() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write( + tmp.path(), + r#" +[jwt.signing] +key = "secret" + +[access] +ui = true +"#, + ) + .unwrap(); + + let cfg = parse_security_config(tmp.path().to_str().unwrap()); + assert_eq!(cfg.jwt_signing_key, b"secret"); + assert!(cfg.access_ui); + } } diff --git a/seaweed-volume/src/main.rs b/seaweed-volume/src/main.rs index da5d4bc85..e1ffae70d 100644 --- a/seaweed-volume/src/main.rs +++ b/seaweed-volume/src/main.rs @@ -177,7 +177,10 @@ async fn run(config: VolumeServerConfig) -> Result<(), Box Response { /// Build the admin (private) HTTP router — supports all operations. pub fn build_admin_router(state: Arc) -> Router { - Router::new() + build_admin_router_with_ui(state.clone(), state.guard.signing_key.0.is_empty()) +} + +/// Build the admin router with an explicit UI exposure flag. +pub fn build_admin_router_with_ui(state: Arc, ui_enabled: bool) -> Router { + let mut router = Router::new() .route("/status", get(handlers::status_handler)) .route("/healthz", get(handlers::healthz_handler)) - .route("/stats/counter", get(handlers::stats_counter_handler)) - .route("/stats/memory", get(handlers::stats_memory_handler)) - .route("/stats/disk", get(handlers::stats_disk_handler)) .route("/favicon.ico", get(handlers::favicon_handler)) .route( "/seaweedfsstatic/*path", get(handlers::static_asset_handler), ) - .route("/ui/index.html", get(handlers::ui_handler)) - .route( - "/", - any( - |_state: State>, request: Request| async move { - match request.method().clone() { - Method::OPTIONS => admin_options_response(), - Method::GET => StatusCode::OK.into_response(), - _ => ( - StatusCode::BAD_REQUEST, - format!("{{\"error\":\"unsupported method {}\"}}", request.method()), - ) - .into_response(), - } - }, - ), - ) + .route("/", any(admin_store_handler)) .route("/:path", any(admin_store_handler)) .route("/:vid/:fid", any(admin_store_handler)) - .route("/:vid/:fid/:filename", any(admin_store_handler)) + .route("/:vid/:fid/:filename", any(admin_store_handler)); + if ui_enabled { + router = router.route("/ui/index.html", get(handlers::ui_handler)); + } + router .layer(middleware::from_fn(common_headers_middleware)) .with_state(state) } @@ -233,24 +223,12 @@ pub fn build_admin_router(state: Arc) -> Router { /// Build the public (read-only) HTTP router — only GET/HEAD. pub fn build_public_router(state: Arc) -> Router { Router::new() - .route("/healthz", get(handlers::healthz_handler)) .route("/favicon.ico", get(handlers::favicon_handler)) .route( "/seaweedfsstatic/*path", get(handlers::static_asset_handler), ) - .route( - "/", - any( - |_state: State>, request: Request| async move { - match request.method().clone() { - Method::OPTIONS => public_options_response(), - Method::GET => StatusCode::OK.into_response(), - _ => StatusCode::OK.into_response(), - } - }, - ), - ) + .route("/", any(public_store_handler)) .route("/:path", any(public_store_handler)) .route("/:vid/:fid", any(public_store_handler)) .route("/:vid/:fid/:filename", any(public_store_handler)) diff --git a/seaweed-volume/tests/http_integration.rs b/seaweed-volume/tests/http_integration.rs index 9b0bddbb5..c09688877 100644 --- a/seaweed-volume/tests/http_integration.rs +++ b/seaweed-volume/tests/http_integration.rs @@ -11,7 +11,8 @@ use tower::ServiceExt; // for `oneshot` use seaweed_volume::security::{Guard, SigningKey}; use seaweed_volume::server::volume_server::{ - build_admin_router, build_metrics_router, VolumeServerState, + build_admin_router, build_admin_router_with_ui, build_metrics_router, build_public_router, + VolumeServerState, }; use seaweed_volume::storage::needle_map::NeedleMapKind; use seaweed_volume::storage::store::Store; @@ -22,6 +23,10 @@ use tempfile::TempDir; /// Create a test VolumeServerState with a temp directory, a single disk /// location, and one pre-created volume (VolumeId 1). fn test_state() -> (Arc, TempDir) { + test_state_with_signing_key(Vec::new()) +} + +fn test_state_with_signing_key(signing_key: Vec) -> (Arc, TempDir) { let tmp = TempDir::new().expect("failed to create temp dir"); let dir = tmp.path().to_str().unwrap(); @@ -40,7 +45,7 @@ fn test_state() -> (Arc, TempDir) { .add_volume(VolumeId(1), "", None, None, 0, DiskType::HardDrive) .expect("failed to create volume"); - let guard = Guard::new(&[], SigningKey(vec![]), 0, SigningKey(vec![]), 0); + let guard = Guard::new(&[], SigningKey(signing_key), 0, SigningKey(vec![]), 0); let state = Arc::new(VolumeServerState { store: RwLock::new(store), guard, @@ -396,3 +401,101 @@ async fn invalid_url_path_returns_400() { "invalid URL path should return 400" ); } + +#[tokio::test] +async fn admin_root_get_returns_400() { + let (state, _tmp) = test_state(); + let app = build_admin_router(state); + + let response = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn public_root_get_returns_400() { + let (state, _tmp) = test_state(); + let app = build_public_router(state); + + let response = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn public_router_does_not_expose_healthz() { + let (state, _tmp) = test_state(); + let app = build_public_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/healthz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn admin_router_does_not_expose_stats_routes() { + let (state, _tmp) = test_state(); + let app = build_admin_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/stats/counter") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn admin_router_hides_ui_when_write_jwt_is_configured() { + let (state, _tmp) = test_state_with_signing_key(b"secret".to_vec()); + let app = build_admin_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/ui/index.html") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn admin_router_can_expose_ui_with_explicit_override() { + let (state, _tmp) = test_state_with_signing_key(b"secret".to_vec()); + let app = build_admin_router_with_ui(state, true); + + let response = app + .oneshot( + Request::builder() + .uri("/ui/index.html") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); +}