Browse Source

fix: align volume router exposure with go

rust-volume-server
Chris Lu 4 days ago
parent
commit
3e5a0738f3
  1. 60
      seaweed-volume/src/config.rs
  2. 5
      seaweed-volume/src/main.rs
  3. 48
      seaweed-volume/src/server/volume_server.rs
  4. 107
      seaweed-volume/tests/http_integration.rs

60
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<u8>,
pub jwt_signing_expires_seconds: i64,
pub jwt_read_signing_key: Vec<u8>,
@ -417,9 +418,9 @@ fn resolve_config(cli: Cli) -> VolumeServerConfig {
.max
.split(',')
.map(|s| {
s.trim()
.parse::<i32>()
.unwrap_or_else(|_| panic!("The max specified in --max is not a valid number: {}", s))
s.trim().parse::<i32>().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<String> = cli
.disk
.split(',')
.map(|s| s.trim().to_string())
.collect();
let mut disk_types: Vec<String> = 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<String>,
}
@ -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::<String>::new());
assert_eq!(tags[1], Vec::<String>::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);
}
}

5
seaweed-volume/src/main.rs

@ -177,7 +177,10 @@ async fn run(config: VolumeServerConfig) -> Result<(), Box<dyn std::error::Error
}
// Build HTTP routers
let mut admin_router = seaweed_volume::server::volume_server::build_admin_router(state.clone());
let mut admin_router = seaweed_volume::server::volume_server::build_admin_router_with_ui(
state.clone(),
config.ui_enabled,
);
if config.pprof {
admin_router = admin_router.merge(build_debug_router());
}

48
seaweed-volume/src/server/volume_server.rs

@ -195,37 +195,27 @@ fn public_options_response() -> Response {
/// Build the admin (private) HTTP router — supports all operations.
pub fn build_admin_router(state: Arc<VolumeServerState>) -> 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<VolumeServerState>, 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<Arc<VolumeServerState>>, 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<VolumeServerState>) -> Router {
/// Build the public (read-only) HTTP router — only GET/HEAD.
pub fn build_public_router(state: Arc<VolumeServerState>) -> 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<Arc<VolumeServerState>>, 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))

107
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<VolumeServerState>, TempDir) {
test_state_with_signing_key(Vec::new())
}
fn test_state_with_signing_key(signing_key: Vec<u8>) -> (Arc<VolumeServerState>, 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<VolumeServerState>, 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);
}
Loading…
Cancel
Save