diff --git a/ostool-server/src/api/router.rs b/ostool-server/src/api/router.rs index bc18a844..49fcdaaa 100644 --- a/ostool-server/src/api/router.rs +++ b/ostool-server/src/api/router.rs @@ -26,7 +26,9 @@ use crate::{ }, }, board_pool::BoardAllocationStatus, - config::{BoardConfig, BootConfig, PowerManagementConfig, ServerConfig, TftpConfig}, + config::{ + BoardConfig, BootConfig, PowerManagementConfig, ServerConfig, TftpConfig, UbootNetworkMode, + }, dtb_store::normalize_dtb_name, power::{PowerAction, PowerActionError}, serial::{ @@ -429,7 +431,7 @@ fn normalize_board_upsert_request( normalize_tags(&mut request.tags); normalize_serial_config(request.serial.as_mut())?; normalize_power_management_config(&mut request.power_management)?; - normalize_boot_config(&mut request.boot); + normalize_boot_config(&mut request.boot)?; if let Some(id) = request.id.as_ref() && (id.contains('/') || id.contains('\\')) @@ -529,15 +531,32 @@ fn normalize_power_management_config( Ok(()) } -fn normalize_boot_config(boot: &mut BootConfig) { +fn normalize_boot_config(boot: &mut BootConfig) -> Result<(), ApiError> { match boot { BootConfig::Uboot(profile) => { normalize_optional_string(&mut profile.dtb_name); + normalize_optional_string(&mut profile.board_ip); + normalize_optional_string(&mut profile.server_ip); + normalize_optional_string(&mut profile.netmask); + normalize_optional_string(&mut profile.gatewayip); + if !profile.use_tftp { + profile.network_mode = UbootNetworkMode::Dhcp; + } + if profile.network_mode == UbootNetworkMode::Dhcp { + profile.board_ip = None; + profile.server_ip = None; + profile.netmask = None; + profile.gatewayip = None; + } + profile + .validate() + .map_err(|err| ApiError::bad_request(format!("{err:#}")))?; } BootConfig::Pxe(profile) => { normalize_optional_string(&mut profile.notes); } } + Ok(()) } fn allocate_board_id(boards: &BTreeMap, board_type: &str) -> String { @@ -925,7 +944,7 @@ async fn get_boot_profile( .ok_or_else(|| ApiError::not_found("session board not found"))?; let network = resolved_board_network(&state, &board).await?; Ok(axum::Json(BootProfileResponse { - boot: board.boot, + boot: boot_profile_with_resolved_network(board.boot, network.as_ref()), server_ip: network.as_ref().and_then(|item| item.server_ip.clone()), netmask: network.as_ref().and_then(|item| item.netmask.clone()), interface: network.as_ref().and_then(|item| item.interface.clone()), @@ -1424,6 +1443,13 @@ fn resolve_server_network(config: &ServerConfig) -> Result, +) -> BootConfig { + let BootConfig::Uboot(mut profile) = boot else { + return boot; + }; + if profile.network_mode == UbootNetworkMode::StaticIp { + if profile.server_ip.is_none() { + profile.server_ip = network.and_then(|item| item.server_ip.clone()); + } + if profile.netmask.is_none() { + profile.netmask = network.and_then(|item| item.netmask.clone()); + } + } + BootConfig::Uboot(profile) } fn dtb_name_header(headers: &HeaderMap, name: &str) -> Result { @@ -1575,7 +1641,10 @@ mod tests { }; use tower::util::ServiceExt; - use super::{DTB_UPLOAD_MAX_MIB, build_router, mib_to_bytes, resolve_server_network}; + use super::{ + DTB_UPLOAD_MAX_MIB, ResolvedNetwork, boot_profile_with_resolved_network, build_router, + mib_to_bytes, resolve_server_network, + }; use crate::{ api::models::{ BoardPowerStatusResponse, BoardRuntimeStatusResponse, SessionDetailResponse, @@ -1584,7 +1653,8 @@ mod tests { config::{ BoardConfig, BootConfig, BuiltinTftpConfig, CustomPowerManagement, PowerManagementConfig, SerialConfig, SerialPortKey, SerialPortKeyKind, ServerConfig, - TftpConfig, UploadLimitsConfig, VirtualPowerManagement, ZhongshengRelayPowerManagement, + TftpConfig, UbootNetworkMode, UploadLimitsConfig, VirtualPowerManagement, + ZhongshengRelayPowerManagement, }, session::SessionLifecycleState, state::BoardLeaseState, @@ -2690,6 +2760,59 @@ mod tests { ); } + #[tokio::test] + async fn create_board_rejects_invalid_static_uboot_network_config() { + let app = test_router().await; + + for boot in [ + json!({ "kind": "uboot", "use_tftp": true, "network_mode": "static_ip" }), + json!({ + "kind": "uboot", + "use_tftp": true, + "network_mode": "static_ip", + "board_ip": "not-an-ip" + }), + json!({ + "kind": "uboot", + "use_tftp": true, + "network_mode": "static_ip", + "board_ip": "192.168.10.20", + "server_ip": "not-an-ip" + }), + json!({ + "kind": "uboot", + "use_tftp": true, + "network_mode": "static_ip", + "board_ip": "192.168.10.20", + "netmask": "not-an-ip" + }), + json!({ + "kind": "uboot", + "use_tftp": true, + "network_mode": "static_ip", + "board_ip": "192.168.10.20", + "gatewayip": "not-an-ip" + }), + ] { + let status = create_board( + &app, + json!({ + "id": null, + "board_type": "rk3568", + "tags": [], + "serial": null, + "power_management": { "kind": "custom", "power_on_cmd": "echo on", "power_off_cmd": "echo off" }, + "boot": boot, + "notes": null, + "disabled": false + }), + ) + .await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + } + } + #[tokio::test] async fn admin_dtb_endpoints_support_create_rename_replace_and_delete() { let app = test_router().await; @@ -3096,6 +3219,88 @@ mod tests { assert_eq!(missing_response.status(), StatusCode::NOT_FOUND); } + #[tokio::test] + async fn boot_profile_returns_static_uboot_network_with_resolved_fallbacks() { + let app = test_router().await; + assert_eq!( + create_board( + &app, + json!({ + "id": "static-profile", + "board_type": "rk3568", + "tags": [], + "serial": null, + "power_management": { "kind": "custom", "power_on_cmd": "echo on", "power_off_cmd": "echo off" }, + "boot": { + "kind": "uboot", + "use_tftp": true, + "network_mode": "static_ip", + "board_ip": "192.168.10.20", + "server_ip": "192.168.10.2", + "netmask": "255.255.255.0", + "gatewayip": "192.168.10.1" + }, + "notes": null, + "disabled": false + }), + ) + .await, + StatusCode::CREATED + ); + let session_id = create_session(&app, "rk3568").await; + + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/v1/sessions/{session_id}/boot-profile")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + StatusCode::OK, + "response body: {}", + String::from_utf8_lossy(&body) + ); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(value["boot"]["network_mode"], "static_ip"); + assert_eq!(value["boot"]["board_ip"], "192.168.10.20"); + assert_eq!(value["boot"]["server_ip"], "192.168.10.2"); + assert_eq!(value["boot"]["netmask"], "255.255.255.0"); + assert_eq!(value["boot"]["gatewayip"], "192.168.10.1"); + assert_eq!(value["server_ip"], "192.168.10.2"); + assert_eq!(value["netmask"], "255.255.255.0"); + } + + #[test] + fn boot_profile_fills_static_uboot_network_from_resolved_server_network() { + let boot = BootConfig::Uboot(crate::config::UbootProfile { + use_tftp: true, + network_mode: UbootNetworkMode::StaticIp, + board_ip: Some("192.168.10.20".into()), + ..Default::default() + }); + let resolved = ResolvedNetwork { + interface: Some("eth0".into()), + server_ip: Some("192.168.10.2".into()), + netmask: Some("255.255.255.0".into()), + }; + + let BootConfig::Uboot(profile) = boot_profile_with_resolved_network(boot, Some(&resolved)) + else { + panic!("expected uboot profile"); + }; + + assert_eq!(profile.server_ip.as_deref(), Some("192.168.10.2")); + assert_eq!(profile.netmask.as_deref(), Some("255.255.255.0")); + } + #[tokio::test] async fn session_file_list_supports_multiple_paths_and_overwrite() { let app = test_router().await; diff --git a/ostool-server/src/board_store/fs.rs b/ostool-server/src/board_store/fs.rs index c84dd9f3..0df7ba30 100644 --- a/ostool-server/src/board_store/fs.rs +++ b/ostool-server/src/board_store/fs.rs @@ -39,6 +39,9 @@ impl FileBoardStore { .with_context(|| format!("failed to read {}", path.display()))?; let board: BoardConfig = toml::from_str(&content) .with_context(|| format!("failed to parse {}", path.display()))?; + board + .validate() + .with_context(|| format!("failed to validate {}", path.display()))?; let stem = path .file_stem() diff --git a/ostool-server/src/config.rs b/ostool-server/src/config.rs index 0b1b356a..aa93b5f0 100644 --- a/ostool-server/src/config.rs +++ b/ostool-server/src/config.rs @@ -352,6 +352,15 @@ pub struct BoardConfig { pub disabled: bool, } +impl BoardConfig { + pub fn validate(&self) -> anyhow::Result<()> { + if let BootConfig::Uboot(profile) = &self.boot { + profile.validate()?; + } + Ok(()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct SerialConfig { pub key: SerialPortKey, @@ -420,6 +429,51 @@ pub struct UbootProfile { #[serde(default)] pub use_tftp: bool, pub dtb_name: Option, + #[serde(default)] + pub network_mode: UbootNetworkMode, + #[serde(default)] + pub board_ip: Option, + #[serde(default)] + pub server_ip: Option, + #[serde(default)] + pub netmask: Option, + #[serde(default)] + pub gatewayip: Option, +} + +impl UbootProfile { + pub fn validate(&self) -> anyhow::Result<()> { + for (field, value) in [ + ("board_ip", self.board_ip.as_deref()), + ("server_ip", self.server_ip.as_deref()), + ("netmask", self.netmask.as_deref()), + ("gatewayip", self.gatewayip.as_deref()), + ] { + if let Some(value) = value { + ensure_ipv4(field, value)?; + } + } + + if self.network_mode == UbootNetworkMode::StaticIp && self.board_ip.is_none() { + bail!("boot.board_ip must be configured when boot.network_mode is static_ip"); + } + + Ok(()) + } +} + +fn ensure_ipv4(field: &str, value: &str) -> anyhow::Result { + value + .parse::() + .with_context(|| format!("boot.{field} must be a valid IPv4 address")) +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum UbootNetworkMode { + #[default] + Dhcp, + StaticIp, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)] @@ -439,7 +493,7 @@ mod tests { use super::{ BoardConfig, BootConfig, CustomPowerManagement, PowerManagementConfig, SerialPortKey, - SerialPortKeyKind, ServerConfig, UbootProfile, VirtualPowerManagement, + SerialPortKeyKind, ServerConfig, UbootNetworkMode, UbootProfile, VirtualPowerManagement, ZhongshengRelayPowerManagement, }; @@ -551,6 +605,92 @@ board_power_off_cmd = "shutdown" ); } + #[test] + fn board_config_defaults_legacy_uboot_network_mode_to_dhcp() { + let config = r#" +id = "demo" +board_type = "demo" +tags = [] +disabled = false + +[power_management] +kind = "custom" +power_on_cmd = "echo on" +power_off_cmd = "echo off" + +[boot] +kind = "uboot" +use_tftp = true +"#; + + let decoded: BoardConfig = toml::from_str(config).unwrap(); + let BootConfig::Uboot(profile) = decoded.boot else { + panic!("expected uboot profile"); + }; + assert_eq!(profile.network_mode, UbootNetworkMode::Dhcp); + } + + #[test] + fn board_config_rejects_static_uboot_network_without_board_ip() { + let config = r#" +id = "demo" +board_type = "demo" +tags = [] +disabled = false + +[power_management] +kind = "custom" +power_on_cmd = "echo on" +power_off_cmd = "echo off" + +[boot] +kind = "uboot" +use_tftp = true +network_mode = "static_ip" +"#; + + let decoded: BoardConfig = toml::from_str(config).unwrap(); + let err = decoded.validate().unwrap_err(); + assert!(err.to_string().contains("board_ip")); + } + + #[test] + fn board_config_rejects_invalid_uboot_network_ipv4_fields() { + for field in ["board_ip", "server_ip", "netmask", "gatewayip"] { + let extra = if field == "board_ip" { + r#"board_ip = "not-an-ip""#.to_string() + } else { + format!("board_ip = \"192.168.10.20\"\n{field} = \"not-an-ip\"") + }; + let config = format!( + r#" +id = "demo" +board_type = "demo" +tags = [] +disabled = false + +[power_management] +kind = "custom" +power_on_cmd = "echo on" +power_off_cmd = "echo off" + +[boot] +kind = "uboot" +use_tftp = true +network_mode = "static_ip" +{extra} +"# + ); + + let decoded: BoardConfig = toml::from_str(&config).unwrap(); + let err = decoded.validate().unwrap_err(); + assert!( + err.to_string().contains(field), + "expected {field} in error, got {err:#}" + ); + } + } + #[test] fn board_config_serialization_omits_removed_fields() { let board = BoardConfig { @@ -565,6 +705,7 @@ board_power_off_cmd = "shutdown" boot: BootConfig::Uboot(UbootProfile { use_tftp: true, dtb_name: Some("board.dtb".into()), + ..Default::default() }), notes: None, disabled: false, diff --git a/ostool-server/src/lib.rs b/ostool-server/src/lib.rs index 5201035c..2e99429f 100644 --- a/ostool-server/src/lib.rs +++ b/ostool-server/src/lib.rs @@ -17,8 +17,8 @@ pub use api::router::build_router; pub use config::{ BoardConfig, BootConfig, BuiltinTftpConfig, CustomPowerManagement, PowerManagementConfig, PxeProfile, SerialConfig, SerialPortKey, SerialPortKeyKind, ServerConfig, SystemTftpdHpaConfig, - TftpConfig, TftpNetworkConfig, UbootProfile, UploadLimitsConfig, VirtualPowerManagement, - ZhongshengRelayPowerManagement, + TftpConfig, TftpNetworkConfig, UbootNetworkMode, UbootProfile, UploadLimitsConfig, + VirtualPowerManagement, ZhongshengRelayPowerManagement, }; pub use dtb_store::{DtbFile, DtbStore}; pub use state::{AppState, BoardLeaseState, build_app_state}; diff --git a/ostool-server/tests/session_ws_lifecycle.rs b/ostool-server/tests/session_ws_lifecycle.rs index ff4db1bb..1d01289d 100644 --- a/ostool-server/tests/session_ws_lifecycle.rs +++ b/ostool-server/tests/session_ws_lifecycle.rs @@ -92,6 +92,7 @@ fn sample_virtual_board(serial_port: String) -> BoardConfig { boot: BootConfig::Uboot(UbootProfile { use_tftp: false, dtb_name: None, + ..Default::default() }), notes: None, disabled: false, diff --git a/ostool-server/webui/package.json b/ostool-server/webui/package.json index a92883b4..9bd7694d 100644 --- a/ostool-server/webui/package.json +++ b/ostool-server/webui/package.json @@ -17,6 +17,7 @@ "@vitejs/plugin-vue": "^5.2.1", "@vue/test-utils": "^2.4.6", "jsdom": "^26.0.0", + "playwright": "^1.60.0", "typescript": "^5.7.2", "vite": "^6.0.5", "vitest": "^2.1.8", diff --git a/ostool-server/webui/pnpm-lock.yaml b/ostool-server/webui/pnpm-lock.yaml index 36de552e..07c76484 100644 --- a/ostool-server/webui/pnpm-lock.yaml +++ b/ostool-server/webui/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: jsdom: specifier: ^26.0.0 version: 26.1.0 + playwright: + specifier: ^1.60.0 + version: 1.60.0 typescript: specifier: ^5.7.2 version: 5.9.3 @@ -785,6 +788,11 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -925,6 +933,16 @@ packages: typescript: optional: true + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -1824,6 +1842,9 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -1970,6 +1991,14 @@ snapshots: transitivePeerDependencies: - '@vue/composition-api' + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.8: dependencies: nanoid: 3.3.11 diff --git a/ostool-server/webui/src/App.vue b/ostool-server/webui/src/App.vue index c3808ea9..b90e8cf0 100644 --- a/ostool-server/webui/src/App.vue +++ b/ostool-server/webui/src/App.vue @@ -37,7 +37,7 @@ const navItems = computed(() => [

开发板管理台

-