From 09e1093b1573049d37714c5db379872c9811935e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Mon, 28 Sep 2026 18:31:58 +0800 Subject: [PATCH 1/7] feat(ostool-server): persist axloader OTA assignments --- Cargo.lock | 2 +- Cargo.toml | 2 +- docs/admin-ui.md | 2 +- docs/api.md | 2 +- docs/axloader-network-control.md | 35 +- httpboot-protocol/Cargo.toml | 2 +- httpboot-protocol/src/lib.rs | 51 +- ostool-server/src/admin_events.rs | 1 + ostool-server/src/api/mod.rs | 1 + ostool-server/src/api/ota.rs | 214 +++++++ ostool-server/src/api/router.rs | 223 +++++++- ostool-server/src/lib.rs | 1 + ostool-server/src/loader.rs | 9 +- ostool-server/src/ota.rs | 529 ++++++++++++++++++ ostool-server/src/state.rs | 4 + ostool-server/webui/src/api/client.ts | 21 + ostool-server/webui/src/api/events.ts | 2 + ostool-server/webui/src/pages/BoardEditor.tsx | 2 + ostool-server/webui/src/pages/BoardOta.tsx | 54 ++ ostool-server/webui/src/types/api.ts | 20 + 20 files changed, 1158 insertions(+), 19 deletions(-) create mode 100644 ostool-server/src/api/ota.rs create mode 100644 ostool-server/src/ota.rs create mode 100644 ostool-server/webui/src/pages/BoardOta.tsx diff --git a/Cargo.lock b/Cargo.lock index 59513f26..26e17387 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1636,7 +1636,7 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpboot-protocol" -version = "0.3.0" +version = "0.4.0" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 55933e62..4240695f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ serde_json = "1" toml = "1.0" url = {version = "2", features = ["serde"]} -httpboot-protocol = { version = "0.3", path = "./httpboot-protocol" } +httpboot-protocol = { version = "0.4", path = "./httpboot-protocol" } # Error handling thiserror = "2" diff --git a/docs/admin-ui.md b/docs/admin-ui.md index 50810962..6c9d8316 100644 --- a/docs/admin-ui.md +++ b/docs/admin-ui.md @@ -63,7 +63,7 @@ data: {"epoch":"...","revision":1,"kind":"snapshot","data":{"boards":[],"session ``` 后续事件名为 `update`,信封字段相同,`data` 仅包含改变的资源集合。 -集合键包括 `quarantined_boards`、`boards`、`runtimes`、`sessions`、`loaders`、`virtual`、`dtbs`、 +集合键包括 `quarantined_boards`、`boards`、`runtimes`、`sessions`、`loaders`、`ota`(镜像和升级任务)、`virtual`、`dtbs`、 `serial`、`network`、`server`、`tftp`、`tftp_status`、`overview`、`power_actions`。 集合更新是该集合的完整替换;消失的实体表示删除。前端按稳定 ID/MAC/name 合并并复用未变化的对象,不重新挂载整张表或表单。 diff --git a/docs/api.md b/docs/api.md index b2b30e3e..82aee804 100644 --- a/docs/api.md +++ b/docs/api.md @@ -670,7 +670,7 @@ Content-Type: application/json 本节定义两种后端共用的开发板服务契约:本地局域网模式由 `ostool-server` 直接提供,认证模式由独立认证后端提供受认证的对应接口。这里覆盖 `ostool-server` 的全部公开、非管理 REST 接口。`ostool` 当前命令会使用会话文件上传;配置宿主 initramfs 时还会调用普通 HTTP Boot 文件上传。它不直接调用会话详情、会话文件列表/查询/删除和显式电源控制;这些仍属于公开 board 服务契约,其中显式电源控制也已有 `BoardServerClient` 方法。 -axloader 协议 v3 另使用 `POST /api/v1/loaders/poll`、`POST /api/v1/loaders/status` 和 `GET /api/v1/sessions/{session_id}/loader-status`。poll/status 由 UDP 发现返回的一次性 `registration_id` 关联本次固件启动;状态以 `session_id + boot_id + registration_id` 定位,旧代次迟到上报不能覆盖新代次。服务端仍接受 v2 loader:仅无 `initramfs` 且无 `cmdline` 的启动可下发;否则 poll 返回 `reject`,代码为 `boot_payload_unsupported`。Session 释放时删除启动清单和 loader 状态。 +axloader 协议 v2/v3/v4 使用 `POST /api/v1/loaders/poll`、`POST /api/v1/loaders/status` 和 `GET /api/v1/sessions/{session_id}/loader-status`。poll/status 由 UDP 发现返回的一次性 `registration_id` 关联本次固件启动;状态以 `session_id + boot_id + registration_id` 定位,旧代次迟到上报不能覆盖新代次。v2 loader 仅在没有 `initramfs` 与 `cmdline` 时接收启动,否则得到 `boot_payload_unsupported`;v3 保留原有启动契约;v4 另携带 `ota` 状态并可收到 `update` / `confirm_update`。Session 释放时删除启动清单和 loader 状态,不删除独立持久的 OTA 任务。管理端镜像上传、指派、取消及设备状态路径见 [axloader 网络控制与虚拟板](axloader-network-control.md#装载器升级接口v4)。 ### 查询开发板类型 diff --git a/docs/axloader-network-control.md b/docs/axloader-network-control.md index ac15d68d..5134b3b0 100644 --- a/docs/axloader-network-control.md +++ b/docs/axloader-network-control.md @@ -1,6 +1,6 @@ # axloader 网络控制与虚拟板 -本文说明 `httpboot-protocol` v3、`axloader`、`ostool-server`、`ostool` CLI 和管理页面之间的网络启动契约。服务端仍接受不使用宿主 initramfs/cmdline 的 v2 loader;该契约不兼容旧版串口 `READY/BOOT` 协议。 +本文说明 `httpboot-protocol` v4、`axloader`、`ostool-server`、`ostool` CLI 和管理页面之间的网络启动及装载器升级契约。服务端继续接受 v2/v3 原有启动请求,只有 v4 可以接收 OTA 任务;旧串口 `READY/BOOT` 协议不受支持。 ## 设计边界 @@ -8,7 +8,7 @@ - `BoardConfig.network_identity.mac_address` 是板卡和配置之间唯一持久绑定;探测记录只驻留内存。 - `board_type`、板卡 ID、电源、串口和启动配置始终由管理员填写。SMBIOS 仅辅助辨认硬件,不推断 `board_type`。 - MAC 是绑定键,不是认证凭据。当前协议用于受信实验室二层网络;HTTP 和镜像 SHA-256 不抵抗同网段主动攻击。 -- 服务重启后会重新读取板卡 TOML,但不恢复旧 Session、启动清单、loader 状态或客户端连接。 +- 服务重启后会重新读取板卡 TOML 和独立的 OTA 镜像库、任务及最近设备升级状态;旧 Session、启动清单、loader 在线状态及客户端连接不恢复。 ## 启动流程 @@ -80,6 +80,37 @@ axloader 如果发现两个不同的 `server_id`,不会随机选择其中一 | `POST /api/v1/admin/virtual-devices` | 管理页面 | 创建并启动一个尚未绑定的 QEMU 设备 | | `DELETE /api/v1/admin/virtual-devices/{id}` | 管理页面 | 停止并删除未绑定虚拟设备 | +### 装载器升级接口(v4) + +| 方法与路径 | 请求/响应 | +| --- | --- | +| `POST /api/v1/admin/loader-images` | 原始 x86_64 EFI,`Content-Length` 必需,最多 32 MiB;可选 `X-Image-Version`,返回服务端计算的 SHA-256、大小和展示版本 | +| `GET /api/v1/admin/loader-images` | 列出持久镜像库 | +| `POST /api/v1/admin/boards/{board_id}/loader-updates` | JSON `{"image_sha256":"..."}`;绑定板卡 MAC,生成单独的 `update_id` | +| `GET /api/v1/admin/boards/{board_id}/loader-updates` | 查看该板卡最近任务和阶段 | +| `DELETE /api/v1/admin/boards/{board_id}/loader-updates/{update_id}` | 仅取消尚未下发的 `queued` 任务 | +| `GET /api/v1/loader-updates/{update_id}/image` | 设备按已指派任务下载镜像 | +| `POST /api/v1/loaders/ota-status` | 设备凭当前发现注册代次上报 `downloading`、`staged` 或 `failed` | + +管理页面在板卡编辑页上传镜像、选择镜像、指派任务、观察 SSE `ota` +快照/阶段,并可取消尚未下发任务。镜像以摘要命名,任务以 UUID 标识; +同一镜像重新指派得到新的任务 ID。任务 JSON、设备最近升级状态和镜像文件 +存放在 `ServerConfig.data_dir/loader-ota`,写入临时文件、`fsync` 后同目录 +替换并同步目录。任务持久化独立于临时 Boot Session。 + +v4 `LoaderPollRequest.ota` 包含当前稳定/运行摘要、待试 ID、来源与上次结果。 +当前板卡 MAC 与注册代次匹配、无 Boot Session 且没有其他待试升级时,服务端 +把任务持久转为 `downloading`,返回 `update`(任务 ID、镜像相对路径、长度和 +摘要)。装载器分块写非活动槽,完成 EFI 校验和记录后重启。新版待试槽的 +首次轮询只有在任务 ID、运行摘要、来源、当前注册和板卡绑定一致时才得到 +`confirm_update`;装载器持久提交,随后轮询报告结果。服务重启会恢复 +`confirming`,并可重发确认。来源为 `direct` 的待试槽必须由直连上传方 +确认,服务端不会替它提交。 + +服务端只在隔离实验网工作;这里的 SHA-256 是完整性检查,MAC 和一次性注册 +ID 都不是身份认证。首次迁移和掉电恢复所需的 A/B 布局、`BOOTX64.EFI` +启动器和离线恢复参见 TGOS `bootloader/axloader/README.md`。 + `boot` 响应中的内核路径必须是当前 server 下的相对路径,同时包含大小、SHA-256、架构、`elf64` 格式和可选入口符号;v3 还可包含宿主归档的相对路径、大小、SHA-256 及命令行。状态更新由 `session_id + boot_id + registration_id` 定位;旧启动命令或旧注册代次的迟到状态返回冲突,不能覆盖当前状态。 ## 板卡配置 diff --git a/httpboot-protocol/Cargo.toml b/httpboot-protocol/Cargo.toml index f40130df..eb43f7b2 100644 --- a/httpboot-protocol/Cargo.toml +++ b/httpboot-protocol/Cargo.toml @@ -7,7 +7,7 @@ keywords = ["httpboot", "uefi", "bootloader"] license = "MIT OR Apache-2.0" name = "httpboot-protocol" repository = "https://github.com/drivercraft/ostool" -version = "0.3.0" +version = "0.4.0" [features] default = ["std", "json"] diff --git a/httpboot-protocol/src/lib.rs b/httpboot-protocol/src/lib.rs index 277f906f..2039993a 100644 --- a/httpboot-protocol/src/lib.rs +++ b/httpboot-protocol/src/lib.rs @@ -8,7 +8,8 @@ use core::{fmt, str::FromStr}; #[cfg(feature = "alloc")] use alloc::{string::String, vec::Vec}; -pub const PROTOCOL_VERSION: u16 = 3; +pub const PROTOCOL_VERSION: u16 = 4; +pub const PREVIOUS_PROTOCOL_VERSION: u16 = 3; pub const LEGACY_PROTOCOL_VERSION: u16 = 2; pub const MAX_HOST_CMDLINE_BYTES: usize = 4095; pub const MAX_HTTP_BOOT_INITRAMFS_BYTES: usize = 256 * 1024 * 1024; @@ -189,6 +190,43 @@ pub struct LoaderPollRequest { pub arch: BootArch, pub loader_version: String, pub hardware: LoaderHardwareInfo, + /// Present only for OTA-aware loaders (protocol v4). + #[cfg_attr( + feature = "json", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub ota: Option, +} + +#[cfg(feature = "alloc")] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoaderOtaState { + pub active_sha256: String, + pub running_sha256: String, + pub pending_update_id: Option, + /// A pending image is runnable only after its attempt record was flushed. + pub trial: bool, + pub source: Option, + pub last_update_id: Option, + pub last_outcome: Option, +} + +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "json", serde(rename_all = "snake_case"))] +pub enum OtaSource { + Direct, + Server, +} + +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "json", serde(rename_all = "snake_case"))] +pub enum OtaOutcome { + Confirmed, + RolledBack, + Failed, } #[cfg(feature = "alloc")] @@ -200,6 +238,17 @@ pub enum LoaderPollResponse { BoundIdle { board_id: String, }, + Update { + board_id: String, + update_id: String, + image_path: String, + image_size: u64, + image_sha256: String, + }, + ConfirmUpdate { + board_id: String, + update_id: String, + }, Boot { board_id: String, session_id: String, diff --git a/ostool-server/src/admin_events.rs b/ostool-server/src/admin_events.rs index 72190f69..0f508721 100644 --- a/ostool-server/src/admin_events.rs +++ b/ostool-server/src/admin_events.rs @@ -22,6 +22,7 @@ pub const TOPICS: &[&str] = &[ "runtimes", "sessions", "loaders", + "ota", "virtual", "dtbs", "serial", diff --git a/ostool-server/src/api/mod.rs b/ostool-server/src/api/mod.rs index 8916f755..13d73518 100644 --- a/ostool-server/src/api/mod.rs +++ b/ostool-server/src/api/mod.rs @@ -1,3 +1,4 @@ pub mod error; pub mod models; +pub mod ota; pub mod router; diff --git a/ostool-server/src/api/ota.rs b/ostool-server/src/api/ota.rs new file mode 100644 index 00000000..0d8bf3d8 --- /dev/null +++ b/ostool-server/src/api/ota.rs @@ -0,0 +1,214 @@ +//! Management and device endpoints for explicit axloader upgrades. + +use axum::{ + Json, Router, + body::{Body, to_bytes}, + extract::{Path, State}, + http::{Request, StatusCode, header}, + routing::{get, post}, +}; +use httpboot_protocol::{LoaderStatusPhase, LoaderStatusReport, MacAddress, PROTOCOL_VERSION}; +use serde::Deserialize; + +use crate::{ + api::{error::ApiError, router::board_id_for_mac}, + ota::{Image, Job, MAX_IMAGE_BYTES, Phase}, + state::{AppState, BoardLeaseState}, +}; + +pub fn routes() -> Router { + Router::new() + .route( + "/api/v1/admin/loader-images", + get(list_images).post(upload_image), + ) + .route( + "/api/v1/admin/boards/{board_id}/loader-updates", + get(get_job).post(queue_job), + ) + .route( + "/api/v1/admin/boards/{board_id}/loader-updates/{update_id}", + axum::routing::delete(cancel_job), + ) + .route( + "/api/v1/loader-updates/{update_id}/image", + get(download_image), + ) + .route("/api/v1/loaders/ota-status", post(report_status)) +} + +async fn list_images(State(state): State) -> Result>, ApiError> { + Ok(Json(state.ota.images()?)) +} + +async fn upload_image( + State(state): State, + request: Request, +) -> Result<(StatusCode, Json), ApiError> { + let length = request + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| ApiError::bad_request("Content-Length required"))?; + if length == 0 || length > MAX_IMAGE_BYTES { + return Err(ApiError::payload_too_large("EFI image exceeds 32 MiB")); + } + let version = request + .headers() + .get("X-Image-Version") + .map(|value| value.to_str().map(str::to_owned)) + .transpose() + .map_err(|_| ApiError::bad_request("invalid image version"))?; + let body = to_bytes(request.into_body(), MAX_IMAGE_BYTES + 1) + .await + .map_err(|_| ApiError::payload_too_large("EFI image exceeds 32 MiB"))?; + if body.len() != length { + return Err(ApiError::bad_request("incorrect Content-Length")); + } + let image = state + .ota + .put_image(&body, version) + .map_err(|error| ApiError::bad_request(format!("{error:#}")))?; + state.admin_events.invalidate(&["ota"]); + Ok((StatusCode::CREATED, Json(image))) +} + +#[derive(Deserialize)] +struct QueueRequest { + image_sha256: String, +} + +async fn queue_job( + Path(board_id): Path, + State(state): State, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + let boards = state.boards.read().await; + let board = boards + .get(&board_id) + .ok_or_else(|| ApiError::not_found("board not found"))?; + let mac = board + .network_identity + .as_ref() + .ok_or_else(|| ApiError::conflict("board lacks a configured MAC"))? + .mac_address; + let runtime = state + .board_runtime_status(&board_id) + .await + .ok_or_else(|| ApiError::not_found("board runtime not found"))?; + if runtime.active_session_id.is_some() || runtime.lease_state != BoardLeaseState::Idle { + return Err(ApiError::conflict("board has an active session")); + } + let job = state + .ota + .queue(board_id, mac, &request.image_sha256) + .await + .map_err(|error| ApiError::conflict(format!("{error:#}")))?; + drop(boards); + state.admin_events.invalidate(&["ota"]); + Ok((StatusCode::CREATED, Json(job))) +} + +async fn get_job( + Path(board_id): Path, + State(state): State, +) -> Result>, ApiError> { + if !state.boards.read().await.contains_key(&board_id) { + return Err(ApiError::not_found("board not found")); + } + Ok(Json(state.ota.job(&board_id).await)) +} + +async fn cancel_job( + Path((board_id, update_id)): Path<(String, String)>, + State(state): State, +) -> Result, ApiError> { + let job = state + .ota + .cancel(&board_id, &update_id) + .await + .map_err(|error| ApiError::conflict(format!("{error:#}")))?; + state.admin_events.invalidate(&["ota"]); + Ok(Json(job)) +} + +async fn download_image( + Path(update_id): Path, + State(state): State, +) -> Result { + let job = state + .ota + .jobs() + .await + .into_iter() + .find(|job| { + job.update_id == update_id + && !matches!( + job.phase, + Phase::Cancelled | Phase::RolledBack | Phase::Failed + ) + }) + .ok_or_else(|| ApiError::not_found("OTA assignment not found"))?; + let boards = state.boards.read().await; + if board_id_for_mac(&boards, job.mac_address).as_deref() != Some(job.board_id.as_str()) { + return Err(ApiError::conflict("board MAC assignment has changed")); + } + let bytes = tokio::fs::read(state.ota.image_path(&job.image.sha256)?) + .await + .map_err(|error| ApiError::internal(format!("failed to read OTA image: {error}")))?; + if bytes.len() as u64 != job.image.size { + return Err(ApiError::internal("stored image length changed")); + } + Ok(Body::from(bytes)) +} + +#[derive(Deserialize)] +struct Report { + protocol_version: u16, + registration_id: String, + mac_address: MacAddress, + update_id: String, + phase: Phase, + error: Option, + active_sha256: Option, +} + +async fn report_status( + State(state): State, + Json(report): Json, +) -> Result { + if report.protocol_version != PROTOCOL_VERSION { + return Err(ApiError::bad_request("OTA requires v4")); + } + state + .loader_registry + .accept_status(&LoaderStatusReport { + protocol_version: report.protocol_version, + registration_id: report.registration_id, + mac_address: report.mac_address, + session_id: String::new(), + boot_id: String::new(), + status: LoaderStatusPhase::Accepted, + }) + .await + .map_err(|error| ApiError::conflict(format!("invalid loader registration: {error:?}")))?; + let boards = state.boards.read().await; + let board_id = board_id_for_mac(&boards, report.mac_address) + .ok_or_else(|| ApiError::conflict("loader MAC is not bound"))?; + state + .ota + .report( + &board_id, + report.mac_address, + &report.update_id, + report.phase, + report.error, + report.active_sha256.as_deref(), + ) + .await + .map_err(|error| ApiError::conflict(format!("{error:#}")))?; + drop(boards); + state.admin_events.invalidate(&["ota"]); + Ok(StatusCode::NO_CONTENT) +} diff --git a/ostool-server/src/api/router.rs b/ostool-server/src/api/router.rs index 6092a76f..07977e1b 100644 --- a/ostool-server/src/api/router.rs +++ b/ostool-server/src/api/router.rs @@ -71,6 +71,7 @@ const DTB_UPLOAD_MAX_MIB: u32 = 10; pub fn build_router(state: AppState) -> Router { Router::new() + .merge(super::ota::routes()) .route("/api/v1/admin/events", get(crate::admin_events::subscribe)) .route( "/api/v1/admin/power-actions", @@ -714,6 +715,44 @@ async fn poll_loader( retry_after_ms: Some(2_000), })); }; + if request.protocol_version == httpboot_protocol::PROTOCOL_VERSION { + let Some(ota) = &request.ota else { + return Ok(axum::Json(LoaderPollResponse::Reject { + code: "ota_state_missing".into(), + message: "protocol v4 requires OTA state".into(), + retry_after_ms: None, + })); + }; + let idle = + runtime.active_session_id.is_none() && runtime.lease_state == BoardLeaseState::Idle; + let decision = state + .ota + .decide(&board_id, request.mac_address, ota, idle) + .await?; + match decision { + crate::ota::Decision::Update(job) => { + state.admin_events.invalidate(&["ota"]); + return Ok(axum::Json(LoaderPollResponse::Update { + board_id, + update_id: job.update_id.clone(), + image_path: format!("/api/v1/loader-updates/{}/image", job.update_id), + image_size: job.image.size, + image_sha256: job.image.sha256, + })); + } + crate::ota::Decision::Confirm(update_id) => { + state.admin_events.invalidate(&["ota"]); + return Ok(axum::Json(LoaderPollResponse::ConfirmUpdate { + board_id, + update_id, + })); + } + crate::ota::Decision::Wait => { + return Ok(axum::Json(LoaderPollResponse::BoundIdle { board_id })); + } + crate::ota::Decision::Idle => {} + } + } let Some(session_id) = runtime.active_session_id else { return Ok(axum::Json(LoaderPollResponse::BoundIdle { board_id })); }; @@ -829,7 +868,10 @@ async fn get_loader_status( .ok_or_else(|| ApiError::not_found("session has no published boot command")) } -fn board_id_for_mac(boards: &BTreeMap, mac: MacAddress) -> Option { +pub(crate) fn board_id_for_mac( + boards: &BTreeMap, + mac: MacAddress, +) -> Option { boards.iter().find_map(|(board_id, board)| { board .network_identity @@ -2725,8 +2767,8 @@ mod tests { }; use httpboot_protocol::{ BootArch, LEGACY_PROTOCOL_VERSION, LoaderDiscoveryProbe, LoaderHardwareInfo, - LoaderPollRequest, LoaderPollResponse, LoaderStatusPhase, LoaderStatusReport, - PROTOCOL_VERSION, + LoaderOtaState, LoaderPollRequest, LoaderPollResponse, LoaderStatusPhase, + LoaderStatusReport, OtaSource, PREVIOUS_PROTOCOL_VERSION, PROTOCOL_VERSION, }; use serde::Serialize; use serde_json::json; @@ -3548,6 +3590,7 @@ mod tests { arch: BootArch::X86_64, loader_version: "test-loader".into(), hardware: LoaderHardwareInfo::default(), + ota: None, }) .await .unwrap(); @@ -4724,7 +4767,7 @@ mod tests { .unwrap(); assert_eq!(published.status(), StatusCode::CREATED); - for version in [PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION] { + for version in [PREVIOUS_PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION] { let probe = LoaderDiscoveryProbe { protocol_version: version, mac_address: mac, @@ -4747,6 +4790,7 @@ mod tests { arch: BootArch::X86_64, loader_version: "test-loader".into(), hardware: LoaderHardwareInfo::default(), + ota: None, }; let response = app .clone() @@ -4820,7 +4864,7 @@ mod tests { serde_json::from_slice(&body).unwrap(); let probe = LoaderDiscoveryProbe { - protocol_version: PROTOCOL_VERSION, + protocol_version: PREVIOUS_PROTOCOL_VERSION, mac_address: mac, current_mac_address: mac, arch: BootArch::X86_64, @@ -4832,7 +4876,7 @@ mod tests { .await .unwrap(); let make_poll = |registration_id: String| LoaderPollRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: PREVIOUS_PROTOCOL_VERSION, registration_id, mac_address: mac, current_mac_address: mac, @@ -4845,6 +4889,7 @@ mod tests { version: None, serial: None, }, + ota: None, }; let first_poll = make_poll(first.registration_id.clone()); @@ -4867,7 +4912,7 @@ mod tests { assert_eq!(boot_id, published.boot_id); let status = LoaderStatusReport { - protocol_version: PROTOCOL_VERSION, + protocol_version: PREVIOUS_PROTOCOL_VERSION, registration_id: first.registration_id.clone(), mac_address: mac, session_id: session_id.clone(), @@ -5712,6 +5757,167 @@ mod tests { assert_eq!(value["code"], "bad_request"); assert_eq!(value["message"], "board_type must not be empty"); } + + #[tokio::test] + async fn v4_upgrade_routes_require_a_board_assignment_and_matching_trial() { + let (app, state) = test_router_and_state_with_config(|_| {}).await; + let board = sample_httpboot_board("ota-board"); + let mac = board.network_identity.as_ref().unwrap().mac_address; + assert_eq!( + create_board(&app, serde_json::to_value(&board).unwrap()).await, + StatusCode::CREATED + ); + let mut image = vec![0; 512]; + image[..2].copy_from_slice(b"MZ"); + image[0x3c..0x40].copy_from_slice(&(0x80_u32).to_le_bytes()); + image[0x80..0x84].copy_from_slice(b"PE\0\0"); + image[0x84..0x86].copy_from_slice(&[0x64, 0x86]); + image[0x98..0x9a].copy_from_slice(&[0x0b, 0x02]); + image[0xdc..0xde].copy_from_slice(&[10, 0]); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/admin/loader-images") + .header(header::CONTENT_LENGTH, image.len()) + .body(Body::from(image.clone())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + let uploaded: crate::ota::Image = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + let queue = || { + Request::builder() + .method("POST") + .uri(format!("/api/v1/admin/boards/{}/loader-updates", board.id)) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + json!({"image_sha256": uploaded.sha256}).to_string(), + )) + .unwrap() + }; + let response = app.clone().oneshot(queue()).await.unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + let job: crate::ota::Job = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!( + app.clone().oneshot(queue()).await.unwrap().status(), + StatusCode::CONFLICT + ); + let probe = LoaderDiscoveryProbe { + protocol_version: PROTOCOL_VERSION, + mac_address: mac, + current_mac_address: mac, + arch: BootArch::X86_64, + loader_version: "ota-test".into(), + }; + let registration = state + .loader_registry + .offer(&probe, "http://127.0.0.1:2999".into()) + .await + .unwrap(); + let mut poll = LoaderPollRequest { + protocol_version: PROTOCOL_VERSION, + registration_id: registration.registration_id.clone(), + mac_address: mac, + current_mac_address: mac, + ip_address: "10.0.2.15".into(), + arch: BootArch::X86_64, + loader_version: "ota-test".into(), + hardware: LoaderHardwareInfo::default(), + ota: Some(LoaderOtaState { + active_sha256: "11".repeat(32), + running_sha256: "11".repeat(32), + pending_update_id: None, + trial: false, + source: None, + last_update_id: None, + last_outcome: None, + }), + }; + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/loaders/poll") + .body(Body::from(serde_json::to_vec(&poll).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + let reply: LoaderPollResponse = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert!( + matches!(reply, LoaderPollResponse::Update { ref update_id, .. } if update_id == &job.update_id) + ); + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/v1/loader-updates/{}/image", job.update_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + to_bytes(response.into_body(), usize::MAX) + .await + .unwrap() + .as_ref(), + image + ); + let trial = poll.ota.as_mut().unwrap(); + trial.pending_update_id = Some(job.update_id.clone()); + trial.running_sha256 = uploaded.sha256; + trial.trial = true; + trial.source = Some(OtaSource::Direct); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/loaders/poll") + .body(Body::from(serde_json::to_vec(&poll).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + let reply: LoaderPollResponse = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert!(matches!(reply, LoaderPollResponse::BoundIdle { .. })); + poll.ota.as_mut().unwrap().source = Some(OtaSource::Server); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/loaders/poll") + .body(Body::from(serde_json::to_vec(&poll).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + let reply: LoaderPollResponse = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert!( + matches!(reply, LoaderPollResponse::ConfirmUpdate { ref update_id, .. } if update_id == &job.update_id) + ); + let response = app.oneshot(Request::builder().method("POST").uri("/api/v1/loaders/ota-status") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(json!({"protocol_version": 4, "registration_id": registration.registration_id, + "mac_address": mac, "update_id": job.update_id, "phase": "succeeded"}).to_string())).unwrap()).await.unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); + } } async fn notify_admin_changes( @@ -5756,6 +5962,9 @@ pub(crate) async fn admin_topic( "boards" => serde_json::to_value(list_boards(extractor).await?.0), "sessions" => serde_json::to_value(list_admin_sessions(extractor).await?.0.sessions), "loaders" => serde_json::to_value(list_loader_devices(extractor).await?.0), + "ota" => { + Ok(serde_json::json!({ "jobs": state.ota.jobs().await, "images": state.ota.images()? })) + } "virtual" => serde_json::to_value(list_virtual_devices(extractor).await.0), "dtbs" => serde_json::to_value(list_dtbs(extractor).await?.0), "serial" => serde_json::to_value(list_serial_ports().await?.0), diff --git a/ostool-server/src/lib.rs b/ostool-server/src/lib.rs index 2d425069..f5b698c8 100644 --- a/ostool-server/src/lib.rs +++ b/ostool-server/src/lib.rs @@ -11,6 +11,7 @@ pub mod dtb_store; pub mod http_boot; pub mod loader; pub mod network_test; +pub mod ota; pub mod power; pub mod process; pub mod serial; diff --git a/ostool-server/src/loader.rs b/ostool-server/src/loader.rs index 24ea301d..238257ea 100644 --- a/ostool-server/src/loader.rs +++ b/ostool-server/src/loader.rs @@ -5,7 +5,7 @@ use chrono::{DateTime, Duration, Utc}; use httpboot_protocol::{ LEGACY_PROTOCOL_VERSION, LoaderDiscoveryOffer, LoaderDiscoveryProbe, LoaderHardwareInfo, LoaderPollRequest, LoaderStatusReport, MAX_DISCOVERY_DATAGRAM_BYTES, MacAddress, - PROTOCOL_VERSION, + PREVIOUS_PROTOCOL_VERSION, PROTOCOL_VERSION, }; use serde::Serialize; use tokio::{net::UdpSocket, sync::Mutex, task::JoinHandle}; @@ -157,7 +157,7 @@ impl LoaderRegistry { ) -> Result { if !matches!( probe.protocol_version, - LEGACY_PROTOCOL_VERSION | PROTOCOL_VERSION + LEGACY_PROTOCOL_VERSION | PREVIOUS_PROTOCOL_VERSION | PROTOCOL_VERSION ) { return Err(RegistrationError::ProtocolVersion); } @@ -190,7 +190,7 @@ impl LoaderRegistry { ) -> Result { if !matches!( request.protocol_version, - LEGACY_PROTOCOL_VERSION | PROTOCOL_VERSION + LEGACY_PROTOCOL_VERSION | PREVIOUS_PROTOCOL_VERSION | PROTOCOL_VERSION ) { return Err(RegistrationError::ProtocolVersion); } @@ -278,7 +278,7 @@ impl LoaderRegistry { ) -> Result<(), RegistrationError> { if !matches!( report.protocol_version, - LEGACY_PROTOCOL_VERSION | PROTOCOL_VERSION + LEGACY_PROTOCOL_VERSION | PREVIOUS_PROTOCOL_VERSION | PROTOCOL_VERSION ) { return Err(RegistrationError::ProtocolVersion); } @@ -476,6 +476,7 @@ mod tests { arch: BootArch::X86_64, loader_version: "test".into(), hardware: LoaderHardwareInfo::default(), + ota: None, } } diff --git a/ostool-server/src/ota.rs b/ostool-server/src/ota.rs new file mode 100644 index 00000000..d2dd20af --- /dev/null +++ b/ostool-server/src/ota.rs @@ -0,0 +1,529 @@ +//! Persistent, board-scoped axloader upgrade assignments. + +use std::{ + collections::BTreeMap, + fs, + io::Write, + path::{Path, PathBuf}, + sync::Arc, +}; + +use anyhow::{Context, ensure}; +use httpboot_protocol::{LoaderOtaState, OtaOutcome, OtaSource}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::sync::Mutex; + +pub const MAX_IMAGE_BYTES: usize = 32 * 1024 * 1024; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Image { + pub sha256: String, + pub size: u64, + pub version: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase { + Queued, + Downloading, + Staged, + Confirming, + Succeeded, + RolledBack, + Failed, + Cancelled, +} + +impl Phase { + fn is_terminal(self) -> bool { + matches!( + self, + Self::Succeeded | Self::RolledBack | Self::Failed | Self::Cancelled + ) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Job { + pub board_id: String, + pub mac_address: httpboot_protocol::MacAddress, + pub update_id: String, + pub image: Image, + pub phase: Phase, + pub error: Option, +} + +#[derive(Clone, Debug)] +pub enum Decision { + Idle, + Update(Job), + Confirm(String), + Wait, +} + +#[derive(Clone)] +pub struct OtaStore { + root: PathBuf, + jobs: Arc>>, + observations: Arc>>, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Observation { + mac_address: httpboot_protocol::MacAddress, + state: LoaderOtaState, +} + +impl OtaStore { + pub fn open(data_dir: &Path) -> anyhow::Result { + let root = data_dir.join("loader-ota"); + fs::create_dir_all(root.join("images"))?; + let jobs = match fs::read(root.join("jobs.json")) { + Ok(bytes) => serde_json::from_slice(&bytes).context("invalid persistent OTA jobs")?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(), + Err(error) => return Err(error.into()), + }; + let observations = match fs::read(root.join("devices.json")) { + Ok(bytes) => serde_json::from_slice(&bytes) + .context("invalid persistent OTA device observations")?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(), + Err(error) => return Err(error.into()), + }; + Ok(Self { + root, + jobs: Arc::new(Mutex::new(jobs)), + observations: Arc::new(Mutex::new(observations)), + }) + } + + pub fn image_path(&self, digest: &str) -> anyhow::Result { + ensure!(valid_digest(digest), "invalid image digest"); + Ok(self.root.join("images").join(format!("{digest}.efi"))) + } + + pub fn image(&self, digest: &str) -> anyhow::Result { + ensure!(valid_digest(digest), "invalid image digest"); + let bytes = fs::read(self.root.join("images").join(format!("{digest}.json")))?; + let image: Image = serde_json::from_slice(&bytes)?; + ensure!( + image.sha256 == digest && self.image_path(digest)?.is_file(), + "missing OTA image" + ); + Ok(image) + } + + pub fn images(&self) -> anyhow::Result> { + let mut images = Vec::new(); + for entry in fs::read_dir(self.root.join("images"))? { + let entry = entry?; + if let Some(name) = entry + .file_name() + .to_str() + .and_then(|name| name.strip_suffix(".json")) + { + images.push(self.image(name)?); + } + } + images.sort_by(|a, b| a.sha256.cmp(&b.sha256)); + Ok(images) + } + + pub fn put_image(&self, bytes: &[u8], version: Option) -> anyhow::Result { + ensure!( + !bytes.is_empty() && bytes.len() <= MAX_IMAGE_BYTES, + "invalid EFI image size" + ); + // DOS header, PE signature, AMD64 machine and EFI application subsystem. + ensure!( + bytes.len() >= 0x40 && &bytes[..2] == b"MZ", + "invalid DOS header" + ); + let pe_offset = u32::from_le_bytes(bytes[0x3c..0x40].try_into()?) as usize; + ensure!( + pe_offset + .checked_add(94) + .is_some_and(|end| end <= bytes.len()), + "invalid PE offset" + ); + ensure!( + &bytes[pe_offset..pe_offset + 4] == b"PE\0\0", + "invalid PE header" + ); + ensure!( + bytes[pe_offset + 4..pe_offset + 6] == [0x64, 0x86], + "unsupported EFI architecture" + ); + let optional = pe_offset + 24; + ensure!( + bytes[optional..optional + 2] == [0x0b, 0x02], + "EFI image must be PE32+" + ); + ensure!( + bytes[optional + 68..optional + 70] == [10, 0], + "EFI image must be an application" + ); + if let Some(label) = &version { + ensure!( + label.len() <= 96 && label.bytes().all(|byte| byte.is_ascii_graphic()), + "invalid image version" + ); + } + let sha256 = format!("{:x}", Sha256::digest(bytes)); + let image = Image { + sha256: sha256.clone(), + size: bytes.len() as u64, + version, + }; + let path = self.image_path(&sha256)?; + if !path.exists() { + atomic_write(&path, bytes)?; + } else { + ensure!( + Sha256::digest(fs::read(&path)?).as_slice() == Sha256::digest(bytes).as_slice(), + "existing image digest mismatch" + ); + } + atomic_write( + &self.root.join("images").join(format!("{sha256}.json")), + &serde_json::to_vec(&image)?, + )?; + Ok(image) + } + + pub async fn jobs(&self) -> Vec { + self.jobs.lock().await.values().cloned().collect() + } + + pub async fn job(&self, board_id: &str) -> Option { + self.jobs.lock().await.get(board_id).cloned() + } + + pub async fn queue( + &self, + board_id: String, + mac_address: httpboot_protocol::MacAddress, + digest: &str, + ) -> anyhow::Result { + let image = self.image(digest)?; + let observations = self.observations.lock().await; + if let Some(device) = observations + .get(&board_id) + .filter(|device| device.mac_address == mac_address) + { + ensure!( + !device.state.trial && device.state.pending_update_id.is_none(), + "device has an unconfirmed OTA trial" + ); + ensure!( + device.state.active_sha256 != image.sha256, + "this image is already the active loader" + ); + } + let mut jobs = self.jobs.lock().await; + ensure!( + jobs.get(&board_id) + .is_none_or(|job| job.phase.is_terminal()), + "board already has an OTA job" + ); + let job = Job { + board_id: board_id.clone(), + mac_address, + update_id: uuid::Uuid::new_v4().to_string(), + image, + phase: Phase::Queued, + error: None, + }; + let mut updated = jobs.clone(); + updated.insert(board_id, job.clone()); + self.save(&updated)?; + *jobs = updated; + drop(observations); + Ok(job) + } + + pub async fn cancel(&self, board_id: &str, update_id: &str) -> anyhow::Result { + let mut jobs = self.jobs.lock().await; + let mut updated = jobs.clone(); + let job = updated.get_mut(board_id).context("no OTA job for board")?; + ensure!( + job.update_id == update_id && job.phase == Phase::Queued, + "OTA job already activated or superseded" + ); + job.phase = Phase::Cancelled; + let result = job.clone(); + self.save(&updated)?; + *jobs = updated; + Ok(result) + } + + pub async fn decide( + &self, + board_id: &str, + mac_address: httpboot_protocol::MacAddress, + ota: &LoaderOtaState, + board_idle: bool, + ) -> anyhow::Result { + let mut observations = self.observations.lock().await; + let changed_observation = observations + .get(board_id) + .is_none_or(|old| old.mac_address != mac_address || old.state != *ota); + if changed_observation { + let mut updated = observations.clone(); + updated.insert( + board_id.into(), + Observation { + mac_address, + state: ota.clone(), + }, + ); + atomic_write( + &self.root.join("devices.json"), + &serde_json::to_vec(&updated)?, + )?; + *observations = updated; + } + let mut jobs = self.jobs.lock().await; + let Some(job) = jobs.get(board_id) else { + return Ok(if ota.trial { + Decision::Wait + } else { + Decision::Idle + }); + }; + if job.mac_address != mac_address { + return Ok(Decision::Wait); + } + let mut changed = jobs.clone(); + let job = changed.get_mut(board_id).unwrap(); + if ota.last_update_id.as_deref() == Some(&job.update_id) { + match ota.last_outcome { + Some(OtaOutcome::Confirmed) + if job.phase == Phase::Confirming && ota.active_sha256 == job.image.sha256 => + { + job.phase = Phase::Succeeded + } + Some(OtaOutcome::RolledBack) => job.phase = Phase::RolledBack, + Some(OtaOutcome::Failed) => job.phase = Phase::Failed, + _ => {} + } + } + let decision = if ota.trial { + if board_idle + && ota.source == Some(OtaSource::Server) + && ota.pending_update_id.as_deref() == Some(&job.update_id) + && ota.running_sha256 == job.image.sha256 + && !job.phase.is_terminal() + { + job.phase = Phase::Confirming; + Decision::Confirm(job.update_id.clone()) + } else { + Decision::Wait + } + } else if job.phase.is_terminal() || !board_idle { + Decision::Idle + } else if ota.active_sha256 == job.image.sha256 { + job.phase = Phase::Failed; + job.error = Some("image already active without this assignment's confirmation".into()); + Decision::Idle + } else if matches!(job.phase, Phase::Queued | Phase::Downloading) { + job.phase = Phase::Downloading; + Decision::Update(job.clone()) + } else { + Decision::Wait + }; + if changed.get(board_id).unwrap().phase != jobs.get(board_id).unwrap().phase { + self.save(&changed)?; + *jobs = changed; + } + Ok(decision) + } + + pub async fn report( + &self, + board_id: &str, + mac_address: httpboot_protocol::MacAddress, + update_id: &str, + phase: Phase, + error: Option, + active_sha256: Option<&str>, + ) -> anyhow::Result<()> { + let observations = self.observations.lock().await; + let mut jobs = self.jobs.lock().await; + let mut updated = jobs.clone(); + let job = updated.get_mut(board_id).context("unknown OTA job")?; + ensure!( + job.mac_address == mac_address && job.update_id == update_id, + "stale OTA report" + ); + ensure!( + matches!( + phase, + Phase::Downloading | Phase::Staged | Phase::Failed | Phase::Succeeded + ), + "invalid loader OTA phase" + ); + ensure!( + !job.phase.is_terminal() || job.phase == phase, + "OTA job already finished" + ); + ensure!( + matches!( + (job.phase, phase), + ( + Phase::Queued | Phase::Downloading, + Phase::Downloading | Phase::Failed + ) | (Phase::Downloading, Phase::Staged) + | (Phase::Staged | Phase::Confirming, Phase::Failed) + | (Phase::Confirming, Phase::Succeeded) + ), + "invalid OTA phase transition" + ); + if phase == Phase::Succeeded { + let observed = observations + .get(board_id) + .context("OTA trial not observed")?; + ensure!( + observed.mac_address == mac_address + && observed.state.source == Some(OtaSource::Server) + && observed.state.trial + && observed.state.pending_update_id.as_deref() == Some(update_id) + && observed.state.running_sha256 == job.image.sha256 + && active_sha256 == Some(job.image.sha256.as_str()), + "confirmed OTA result does not match registered trial" + ); + } + if job.phase != phase || job.error != error { + job.phase = phase; + job.error = error; + self.save(&updated)?; + *jobs = updated; + } + Ok(()) + } + + fn save(&self, jobs: &BTreeMap) -> anyhow::Result<()> { + atomic_write(&self.root.join("jobs.json"), &serde_json::to_vec(jobs)?) + } +} + +fn valid_digest(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> { + let tmp = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4())); + let result = (|| -> anyhow::Result<()> { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp)?; + file.write_all(bytes)?; + file.sync_all()?; + fs::rename(&tmp, path)?; + fs::File::open(path.parent().context("OTA path without parent")?)?.sync_all()?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&tmp); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use httpboot_protocol::{LoaderOtaState, OtaOutcome, OtaSource}; + + fn efi_image() -> Vec { + let mut bytes = vec![0; 512]; + bytes[..2].copy_from_slice(b"MZ"); + bytes[0x3c..0x40].copy_from_slice(&(0x80_u32).to_le_bytes()); + bytes[0x80..0x84].copy_from_slice(b"PE\0\0"); + bytes[0x84..0x86].copy_from_slice(&[0x64, 0x86]); + bytes[0x98..0x9a].copy_from_slice(&[0x0b, 0x02]); + bytes[0xdc..0xde].copy_from_slice(&[10, 0]); + bytes + } + + #[tokio::test] + async fn update_survives_server_restart_and_only_confirms_its_own_trial() { + let dir = tempfile::tempdir().unwrap(); + let store = OtaStore::open(dir.path()).unwrap(); + let image = store.put_image(&efi_image(), Some("test".into())).unwrap(); + let mac = "02:00:00:00:00:01".parse().unwrap(); + let job = store + .queue("board-1".into(), mac, &image.sha256) + .await + .unwrap(); + assert!( + store + .queue("board-1".into(), mac, &image.sha256) + .await + .is_err() + ); + drop(store); + let store = OtaStore::open(dir.path()).unwrap(); + let mut state = LoaderOtaState { + active_sha256: "11".repeat(32), + running_sha256: "11".repeat(32), + pending_update_id: None, + trial: false, + source: None, + last_update_id: None, + last_outcome: None, + }; + assert!(matches!( + store.decide("board-1", mac, &state, false).await.unwrap(), + Decision::Idle + )); + assert!(matches!( + store.decide("board-1", mac, &state, true).await.unwrap(), + Decision::Update(_) + )); + state.pending_update_id = Some(job.update_id.clone()); + state.trial = true; + state.running_sha256 = image.sha256.clone(); + state.source = Some(OtaSource::Direct); + assert!(matches!( + store.decide("board-1", mac, &state, true).await.unwrap(), + Decision::Wait + )); + state.source = Some(OtaSource::Server); + assert!( + matches!(store.decide("board-1", mac, &state, true).await.unwrap(), Decision::Confirm(id) if id == job.update_id) + ); + drop(store); + let store = OtaStore::open(dir.path()).unwrap(); + assert_eq!(store.job("board-1").await.unwrap().phase, Phase::Confirming); + state.trial = false; + state.pending_update_id = None; + state.active_sha256 = image.sha256; + state.last_update_id = Some(job.update_id); + state.last_outcome = Some(OtaOutcome::Confirmed); + assert!(matches!( + store.decide("board-1", mac, &state, true).await.unwrap(), + Decision::Idle + )); + assert_eq!(store.job("board-1").await.unwrap().phase, Phase::Succeeded); + let mut newer = efi_image(); + newer.push(1); + let candidate = store.put_image(&newer, None).unwrap(); + state.trial = true; + state.pending_update_id = Some("01234567-89ab-cdef-0123-456789abcdef".into()); + state.source = Some(OtaSource::Direct); + assert!(matches!( + store.decide("board-1", mac, &state, true).await.unwrap(), + Decision::Wait + )); + drop(store); + let store = OtaStore::open(dir.path()).unwrap(); + assert!( + store + .queue("board-1".into(), mac, &candidate.sha256) + .await + .is_err() + ); + } +} diff --git a/ostool-server/src/state.rs b/ostool-server/src/state.rs index 4174359e..1c653990 100644 --- a/ostool-server/src/state.rs +++ b/ostool-server/src/state.rs @@ -20,6 +20,7 @@ use crate::{ config::{BoardConfig, PowerManagementConfig, ServerConfig}, dtb_store::DtbStore, loader::LoaderRegistry, + ota::OtaStore, power::{PowerAction, PowerActionError, execute_power_action_for_board}, session::{Session, SessionState, SessionStopReason}, tftp::service::TftpManager, @@ -105,6 +106,7 @@ pub struct AppState { pub board_runtimes: Arc>>, pub sessions: Arc>>>, pub loader_registry: LoaderRegistry, + pub ota: OtaStore, pub virtual_boards: VirtualBoardManager, pub(crate) board_inventory_gate: Arc>, pub board_store: Arc, @@ -127,6 +129,7 @@ pub async fn build_app_state( let (release_tx, release_rx) = mpsc::unbounded_channel(); let admin_events = crate::admin_events::AdminEvents::default(); + let ota = OtaStore::open(&config.data_dir)?; let mut virtual_boards = VirtualBoardManager::new(config.virtual_qemu.clone()); virtual_boards.set_events(admin_events.clone()); if virtual_boards.enabled() { @@ -155,6 +158,7 @@ pub async fn build_app_state( board_runtimes: Arc::new(RwLock::new(board_runtimes)), sessions: Arc::new(RwLock::new(BTreeMap::new())), loader_registry: LoaderRegistry::with_events(admin_events), + ota, virtual_boards, board_inventory_gate: Arc::new(Mutex::new(())), board_store, diff --git a/ostool-server/webui/src/api/client.ts b/ostool-server/webui/src/api/client.ts index a4921a0b..477981b8 100644 --- a/ostool-server/webui/src/api/client.ts +++ b/ostool-server/webui/src/api/client.ts @@ -9,6 +9,8 @@ import type { DtbFileResponse, ErrorResponse, LoaderDeviceSummary, + LoaderImage, + LoaderUpdateJob, NetworkInterfaceSummary, SerialPortSummary, TftpConfig, @@ -62,6 +64,25 @@ async function request( } export const api = { + uploadLoaderImage(file: File, version?: string) { + return request("/api/v1/admin/loader-images", { + method: "POST", + headers: version ? { "X-Image-Version": version } : undefined, + body: file, + }); + }, + queueLoaderUpdate(boardId: string, imageSha256: string) { + return request( + `/api/v1/admin/boards/${encodeURIComponent(boardId)}/loader-updates`, + { method: "POST", bodyJson: { image_sha256: imageSha256 } }, + ); + }, + cancelLoaderUpdate(boardId: string, updateId: string) { + return request( + `/api/v1/admin/boards/${encodeURIComponent(boardId)}/loader-updates/${encodeURIComponent(updateId)}`, + { method: "DELETE" }, + ); + }, powerAction(payload: { request_id: string; action: "on" | "off"; diff --git a/ostool-server/webui/src/api/events.ts b/ostool-server/webui/src/api/events.ts index bb20c00a..453e9ed9 100644 --- a/ostool-server/webui/src/api/events.ts +++ b/ostool-server/webui/src/api/events.ts @@ -5,6 +5,7 @@ import type { BoardConfig, Session, LoaderDeviceSummary, + LoaderOtaResources, VirtualDevicesResponse, DtbFileResponse, SerialPortSummary, @@ -34,6 +35,7 @@ export interface Resources { sessions: Session[]; runtimes: Record; loaders: LoaderDeviceSummary[]; + ota: LoaderOtaResources; virtual: VirtualDevicesResponse; dtbs: DtbFileResponse[]; serial: SerialPortSummary[]; diff --git a/ostool-server/webui/src/pages/BoardEditor.tsx b/ostool-server/webui/src/pages/BoardEditor.tsx index 4bfcd1ad..42d18c89 100644 --- a/ostool-server/webui/src/pages/BoardEditor.tsx +++ b/ostool-server/webui/src/pages/BoardEditor.tsx @@ -35,6 +35,7 @@ import { } from "@/components/forms"; import { MacPicker } from "@/components/mac-picker"; import { DtbUpload } from "./Dtbs"; +import { BoardOta } from "./BoardOta"; export default function BoardEditor() { const { boardId } = useParams(); @@ -538,6 +539,7 @@ function Editor({ board }: { board?: BoardConfig }) { + {board?.network_identity && } ); } diff --git a/ostool-server/webui/src/pages/BoardOta.tsx b/ostool-server/webui/src/pages/BoardOta.tsx new file mode 100644 index 00000000..f5c8dd90 --- /dev/null +++ b/ostool-server/webui/src/pages/BoardOta.tsx @@ -0,0 +1,54 @@ +import { useState } from "react"; +import { api } from "@/api/client"; +import { useResource } from "@/api/events"; +import { Button } from "@/components/ui/button"; +import { Section, SelectField, Notice, ConfirmAction, useAction } from "@/components/forms"; + +export function BoardOta({ boardId }: { boardId: string }) { + const ota = useResource("ota"); + const [selected, setSelected] = useState(""); + const [file, setFile] = useState(null); + const upload = useAction(); + const assign = useAction(); + const job = ota?.jobs.find((entry) => entry.board_id === boardId); + const pending = job && !["succeeded", "rolled_back", "failed", "cancelled"].includes(job.phase); + return ( +
+ {job && ( +
+

任务 {job.update_id} · {job.phase} · 镜像 {job.image.version || job.image.sha256.slice(0, 12)}

+ {job.error && {job.error}} + {pending && job.phase === "queued" && ( + api.cancelLoaderUpdate(boardId, job.update_id)} /> + )} +
+ )} + + + ({ + value: image.sha256, + label: `${image.version || image.sha256.slice(0, 12)} · ${(image.size / 1024 / 1024).toFixed(2)} MiB`, + })), + ]} /> + + {(upload.error || assign.error) && {upload.error || assign.error}} +
+ ); +} diff --git a/ostool-server/webui/src/types/api.ts b/ostool-server/webui/src/types/api.ts index b0cc48f4..025ba4d3 100644 --- a/ostool-server/webui/src/types/api.ts +++ b/ostool-server/webui/src/types/api.ts @@ -180,6 +180,26 @@ export interface LoaderDeviceSummary { current_registration_id: string | null; } +export interface LoaderImage { + sha256: string; + size: number; + version: string | null; +} + +export interface LoaderUpdateJob { + board_id: string; + mac_address: string; + update_id: string; + image: LoaderImage; + phase: "queued" | "downloading" | "staged" | "confirming" | "succeeded" | "rolled_back" | "failed" | "cancelled"; + error: string | null; +} + +export interface LoaderOtaResources { + jobs: LoaderUpdateJob[]; + images: LoaderImage[]; +} + export interface VirtualDeviceSummary { id: string; mac_address: string; From 67ddda2a74acd533db5be7b3126d5ab6ec47b567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Tue, 29 Sep 2026 10:38:23 +0800 Subject: [PATCH 2/7] feat(ostool-server): control v5 axloader devices over HTTP --- Cargo.lock | 1 + docs/axloader-network-control.md | 291 +++++----------- httpboot-protocol/src/lib.rs | 64 ++++ ostool-server/Cargo.toml | 1 + ostool-server/scripts/test-axloader-local.py | 302 +++++++++++++++++ ostool-server/src/api/router.rs | 132 ++++---- ostool-server/src/device.rs | 338 +++++++++++++++++++ ostool-server/src/lib.rs | 1 + ostool-server/src/loader.rs | 153 ++++++++- 9 files changed, 1008 insertions(+), 275 deletions(-) create mode 100644 ostool-server/scripts/test-axloader-local.py create mode 100644 ostool-server/src/device.rs diff --git a/Cargo.lock b/Cargo.lock index 26e17387..96c1fcf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2773,6 +2773,7 @@ dependencies = [ "nix 0.30.1", "notify", "nusb", + "reqwest", "rust-embed", "schemars", "serde", diff --git a/docs/axloader-network-control.md b/docs/axloader-network-control.md index 5134b3b0..87da3472 100644 --- a/docs/axloader-network-control.md +++ b/docs/axloader-network-control.md @@ -1,233 +1,116 @@ -# axloader 网络控制与虚拟板 +# axloader 网络控制与本地验证 -本文说明 `httpboot-protocol` v4、`axloader`、`ostool-server`、`ostool` CLI 和管理页面之间的网络启动及装载器升级契约。服务端继续接受 v2/v3 原有启动请求,只有 v4 可以接收 OTA 任务;旧串口 `READY/BOOT` 协议不受支持。 +## 1. 设备所有权 -## 设计边界 +### 1.1 入口与发现 -- 控制面只使用 UDP 发现和 HTTP。串口只承载目标系统的原始输入输出。 -- `BoardConfig.network_identity.mac_address` 是板卡和配置之间唯一持久绑定;探测记录只驻留内存。 -- `board_type`、板卡 ID、电源、串口和启动配置始终由管理员填写。SMBIOS 仅辅助辨认硬件,不推断 `board_type`。 -- MAC 是绑定键,不是认证凭据。当前协议用于受信实验室二层网络;HTTP 和镜像 SHA-256 不抵抗同网段主动攻击。 -- 服务重启后会重新读取板卡 TOML 和独立的 OTA 镜像库、任务及最近设备升级状态;旧 Session、启动清单、loader 在线状态及客户端连接不恢复。 +协议 v5 将 HTTP 控制入口放在 axloader。`network::Announcer` 在同一 UEFI 网卡 +向 UDP `2998` 单向广播 MAC、架构、启动代次和设备 TCP4 端口 `2999`; +`direct::Listener` 在该端口处理启动与 OTA。ostool-server 的 +`loader::serve_udp_discovery()` 对 v5 广播调用 `device::reconcile()`,先反向 +GET 设备状态,核对 MAC、代次和架构,才从板卡 TOML 中查找当前绑定。 +发现不要求服务端响应:直连客户端知道设备 IP 时可独立控制装载器。 -## 启动流程 +广播和 HTTP 没有身份认证。MAC 仅作板卡配置绑定键;报文和镜像的 SHA-256 +仅检验一致性,本阶段限定可信隔离实验网。服务器对重复 MAC 且不同来源 IP +的在线设备停止下发任务,新的启动代次替代同地址的旧实例;设备拒绝旧代次的 +修改请求。 + +### 1.2 启动交接 + +`BootServer` 在内存维护一个 `DeviceBootJob`。内核和可选 initramfs 由调用方 +PUT 上传,各自按清单长度及 SHA-256 检验,每个文件不超过 256 MiB。 +`POST /api/v1/boot/jobs/{id}/start` 验证 ELF,复制已核对摘要的归档并发布宿主配置表,回复 +`ready_to_handoff` 后释放 TCP4 与 UDP4 对象,进入既有 UEFI 内核交接。 +没有 ESP 写入或 ostool-server 时,同样可以从设备 IP 直连完成启动。 ```mermaid sequenceDiagram participant L as axloader - participant S as ostool-server - participant C as ostool CLI - participant T as 目标系统串口 - - L->>L: UEFI 同一控制器取得 SNP/IP4/UDP4/HTTP - L->>S: UDP :2998 DiscoveryProbe - S-->>L: DiscoveryOffer + registration_id - loop 未绑定或没有启动命令 - L->>S: POST /api/v1/loaders/poll - S-->>L: unbound / bound_idle / reject - end - C->>S: 创建 Session;可选上传宿主 initramfs,再上传 ELF 并发布启动清单 - C->>S: 连接串口 WebSocket(自动上电) - L->>S: POST /api/v1/loaders/poll - S-->>L: boot + session_id + boot_id + 内核摘要及可选归档摘要/cmdline - L->>S: accepted / downloading - L->>S: GET 相对 kernel_path - opt 启动清单包含宿主 initramfs - L->>S: GET 同一 Session 的 initramfs.path - end - L->>L: 校验文件长度和 SHA-256,装载 ELF/归档 - L->>S: verified / ready_to_handoff - L->>L: 销毁 UDP/HTTP/IP 对象并 ExitBootServices - T-->>C: 目标系统原始串口输出 - C--xS: WebSocket 关闭 - S->>S: SerialClosed → releasing → 断电 → idle + participant S as ostool-server 或直连工具 + L-->>S: UDP :2998 广播(可选) + S->>L: GET /api/v1/status + S->>L: POST /api/v1/boot/jobs(X-Boot-Epoch) + S->>L: PUT kernel;可选 PUT initramfs + S->>L: POST /api/v1/boot/jobs/{id}/start + L-->>S: 202 ready_to_handoff + L->>L: 释放网络对象并交接内核 ``` -`ready_to_handoff` 是最后一个可靠网络状态。它不会消费启动清单;同一 Session 内板卡重启后,新 `registration_id` 会重新取得相同 `boot_id`。上传新内核才会以新 `boot_id` 替换旧命令。Session 释放时,启动命令和 loader 状态一起删除。 - -宿主归档由 CLI 先以会话内路径 `initramfs.cpio` 上传,再在发布内核时以 -`X-HttpBoot-Initramfs-Path` 引用;`X-HttpBoot-Cmdline` 可独立提供命令行。 -服务端读取同一 Session 的归档并记录大小、SHA-256,拒绝空文件或超过 -256 MiB 的归档;命令行最长 4095 字节,仅允许可打印 ASCII 和空格。 -v3 loader 从启动清单取得可选的 `initramfs: {path, size, sha256}` 和 -`cmdline`,从当前 Session 下载并再次核对归档,失败时不上交内核。 -v2 loader 遇到任一新字段时收到 `boot_payload_unsupported`,没有新字段的 -启动保持兼容。摘要用于发现不一致,不替代受信网络或认证。 - -## 发现和注册代次 - -axloader 向当前 IPv4 子网定向广播地址的 UDP `2998` 发送 JSON。数据报不得超过 1400 字节,只含协议版本、永久 MAC、当前链路 MAC、架构和 loader 版本。SMBIOS Type 1 详情在 HTTP poll 中上报。 - -server 为每次发现签发一次性 `registration_id`,默认有效期为 30 秒。第一次 poll 后它代表当前 loader 启动代次: +ostool-server 保留原有 Session、串口 WebSocket、启动清单和板卡租约。会话 +上传文件后,`device::push_boot()` 从会话存储读取并重新核对长度与摘要, +再调用设备接口;它不把文件 URL 交给装载器。设备在同一 Session 内复位时, +服务器观察新启动代次并按原 `boot_id` 重新推送。串口只承载目标系统输出。 -- 新代次可以替换不再上报的旧代次; -- 被替换的旧代次若再次上报,则该 MAC 进入冲突状态; -- 冲突期间 server 返回 `duplicate_mac`,不下发启动命令; -- 10 秒没有 poll 的设备显示为离线,探测记录保留 24 小时; -- 每次 poll 都从当前板卡 TOML 重新计算 `bound_board_id`,不保存第二份绑定关系。 +## 2. 设备协议 -axloader 如果发现两个不同的 `server_id`,不会随机选择其中一个,而是按 1、2、4、8、10 秒封顶退避重新发现。 +### 2.1 启动事务 -## HTTP 接口 +所有修改调用携带当前 `/api/v1/status` 返回的 `X-Boot-Epoch`。 +`BootServer::create()` 只接受 x86_64 ELF64、非空合法启动 ID、预期长度与 +摘要,以及合法的可选命令行;相同 ID 和清单的重试保留已接收文件, +冲突清单返回 `409`。设备只保留一个启动事务,可在启动前取消。 -| 方法与路径 | 调用者 | 含义 | -| --- | --- | --- | -| `POST /api/v1/loaders/poll` | axloader | 注册或刷新设备,返回 `unbound`、`bound_idle`、`boot` 或 `reject` | -| `POST /api/v1/loaders/status` | axloader | 上报 `accepted`、`downloading`、`verified`、`ready_to_handoff` 或 `failed` | -| `GET /api/v1/admin/loader-devices` | 管理页面 | 查询探测设备、绑定、在线和冲突状态 | -| `GET /api/v1/sessions/{id}/loader-status` | CLI/管理工具 | 查询当前 Session 的 `boot_id`、`registration_id` 和状态 | -| `GET /api/v1/admin/virtual-devices` | 管理页面 | 查询虚拟板功能开关和进程状态 | -| `POST /api/v1/admin/virtual-devices` | 管理页面 | 创建并启动一个尚未绑定的 QEMU 设备 | -| `DELETE /api/v1/admin/virtual-devices/{id}` | 管理页面 | 停止并删除未绑定虚拟设备 | - -### 装载器升级接口(v4) - -| 方法与路径 | 请求/响应 | +| 方法与路径 | 结果 | | --- | --- | -| `POST /api/v1/admin/loader-images` | 原始 x86_64 EFI,`Content-Length` 必需,最多 32 MiB;可选 `X-Image-Version`,返回服务端计算的 SHA-256、大小和展示版本 | -| `GET /api/v1/admin/loader-images` | 列出持久镜像库 | -| `POST /api/v1/admin/boards/{board_id}/loader-updates` | JSON `{"image_sha256":"..."}`;绑定板卡 MAC,生成单独的 `update_id` | -| `GET /api/v1/admin/boards/{board_id}/loader-updates` | 查看该板卡最近任务和阶段 | -| `DELETE /api/v1/admin/boards/{board_id}/loader-updates/{update_id}` | 仅取消尚未下发的 `queued` 任务 | -| `GET /api/v1/loader-updates/{update_id}/image` | 设备按已指派任务下载镜像 | -| `POST /api/v1/loaders/ota-status` | 设备凭当前发现注册代次上报 `downloading`、`staged` 或 `failed` | - -管理页面在板卡编辑页上传镜像、选择镜像、指派任务、观察 SSE `ota` -快照/阶段,并可取消尚未下发任务。镜像以摘要命名,任务以 UUID 标识; -同一镜像重新指派得到新的任务 ID。任务 JSON、设备最近升级状态和镜像文件 -存放在 `ServerConfig.data_dir/loader-ota`,写入临时文件、`fsync` 后同目录 -替换并同步目录。任务持久化独立于临时 Boot Session。 - -v4 `LoaderPollRequest.ota` 包含当前稳定/运行摘要、待试 ID、来源与上次结果。 -当前板卡 MAC 与注册代次匹配、无 Boot Session 且没有其他待试升级时,服务端 -把任务持久转为 `downloading`,返回 `update`(任务 ID、镜像相对路径、长度和 -摘要)。装载器分块写非活动槽,完成 EFI 校验和记录后重启。新版待试槽的 -首次轮询只有在任务 ID、运行摘要、来源、当前注册和板卡绑定一致时才得到 -`confirm_update`;装载器持久提交,随后轮询报告结果。服务重启会恢复 -`confirming`,并可重发确认。来源为 `direct` 的待试槽必须由直连上传方 -确认,服务端不会替它提交。 - -服务端只在隔离实验网工作;这里的 SHA-256 是完整性检查,MAC 和一次性注册 -ID 都不是身份认证。首次迁移和掉电恢复所需的 A/B 布局、`BOOTX64.EFI` -启动器和离线恢复参见 TGOS `bootloader/axloader/README.md`。 - -`boot` 响应中的内核路径必须是当前 server 下的相对路径,同时包含大小、SHA-256、架构、`elf64` 格式和可选入口符号;v3 还可包含宿主归档的相对路径、大小、SHA-256 及命令行。状态更新由 `session_id + boot_id + registration_id` 定位;旧启动命令或旧注册代次的迟到状态返回冲突,不能覆盖当前状态。 - -## 板卡配置 - -HTTP Boot 实体板示例: - -```toml -id = "rk3568-01" -board_type = "RK3568" -disabled = false -tags = ["arm64"] - -[network_identity] -mac_address = "02:11:22:33:44:55" - -[serial] -baud_rate = 1500000 - -[serial.key] -kind = "serial_number" -value = "USB-UART-01" - -[power_management] -kind = "custom" -power_on_cmd = "board-power rk3568-01 on" -power_off_cmd = "board-power rk3568-01 off" - -[boot] -kind = "httpboot" -boot_arch = "aarch64" -``` +| `GET /api/v1/status` | 返回 v5、MAC、启动代次、硬件、启动事务及 OTA 状态 | +| `POST /api/v1/boot/jobs` | 提交 `DeviceBootJob`;创建返回 `201`,幂等重试返回 `200` | +| `GET /api/v1/boot/jobs/{id}` | 查询事务阶段及已接收文件 | +| `PUT /api/v1/boot/jobs/{id}/kernel` | 原始内核,必须带定长和 `X-Image-Sha256` | +| `PUT /api/v1/boot/jobs/{id}/initramfs` | 原始可选归档,采用相同校验规则 | +| `POST /api/v1/boot/jobs/{id}/start` | 文件齐备且装载成功返回 `202` 并交接 | +| `DELETE /api/v1/boot/jobs/{id}` | 未交接时释放事务和文件 | -MAC 保存为小写六字节冒号格式并全局唯一。HTTP Boot 板卡缺少 MAC 时配置无效。只有板卡处于 `idle` 时才允许修改 MAC、重命名或删除;使用中和释放中返回 `409 Conflict`。重复绑定返回错误码 `mac_already_bound`。 +请求头限制 4 KiB;仅接受 `Content-Length`,连接空闲 30 秒后取消,短读和 +错误摘要或上传中断释放启动事务。长时间接收过程中等待路径继续驱动 UDP 广播;单个设备 +一次只处理一个 TCP 连接。需要同时上传或查询的客户端应等当前请求完成。 -管理页面通过 `/api/v1/admin/events` 接收设备快照与增量事件,取消定时刷新。创建页可以在未保存板卡、未填写 MAC 时直接按当前电源配置手动上电或下电;设备上报后实时出现在 MAC 选择列表。选择探测设备只会把 MAC 带入编辑器并展示 IP、架构、loader 版本和 SMBIOS;不会自动填写板卡 ID、`board_type`、电源、串口或启动设置。也可以手工输入 MAC。 +### 2.2 装载器升级 -## ostool CLI 行为 +设备复用 `ota::OtaContext` 和现有 A/B 启动器:流式写入非活动槽,完成文件 +摘要、PE 架构与 UEFI 加载校验后才持久记录待试升级并复位。待试槽未收到 +相同升级 ID、相同来源的确认时拒绝内核启动;复位或加载失败由启动器回滚。 -HTTP Boot runner 按以下顺序工作: - -1. 按人工配置的 `board_type` 创建 Session; -2. 可选地上传宿主归档到该 Session;上传 ELF 时引用归档路径并发布新的 `boot_id`; -3. 立即连接串口 WebSocket,由现有串口生命周期自动上电; -4. 并行读取原始串口并轮询 loader status; -5. 活动 Session 内板卡重启时继续等待新注册代次,不重建 Session; -6. loader 报告 `failed` 时结束;正常成功仍以目标系统串口成功条件为准; -7. WebSocket 关闭后沿用 `SerialClosed` 释放流程并断电。 +| 方法与路径 | 结果 | +| --- | --- | +| `GET /api/v1/ota/status` | 当前槽、摘要、待试 ID 与最近结果 | +| `PUT /api/v1/ota/image` | 32 MiB 以内的原始 EFI,`X-Image-Sha256` 必需,成功返回 `202` 并复位 | +| `POST /api/v1/ota/confirm` | JSON 升级 ID;只有对应待试槽可以持久提交 | -`.board.toml` 中的 `initramfs` 和 `cmdline` 都是可选字段。前者是宿主归档, -不是虚拟机内 Linux guest 的 initrd;后者传给宿主内核。只有目标 loader、 -固件与内核实现了对应交接时才配置这些字段。 +直连上传默认生成 ID,且来源为 `direct`。服务端指派传入持久任务 ID, +以及 `X-Update-Source: server`;`OtaStore::decide()` 只在板卡空闲且非其他 +待试升级时下发。新槽广播后,服务端核对当前板卡绑定、升级 ID、运行摘要与 +来源,才调用确认接口;设备持久提交后服务器任务变为成功。服务重启读取独立 +于 Session 的镜像库和任务。直连升级不会被服务端自动确认。 -## 内建 QEMU 虚拟板 +## 3. 兼容与本地联调 -虚拟板默认关闭。第一阶段固定为 `x86_64 + OVMF + q35`,使用 TCG,协议类型仍保留其他架构值。示例 server 配置: +### 3.1 兼容边界 -```toml -[loader_network] -enabled = true -bind_addr = "0.0.0.0:2998" -public_base_url = "http://10.77.0.1:2999" +ostool-server 继续接受旧装载器 v2/v3/v4 的 UDP Offer、 +`POST /api/v1/loaders/poll`、状态上报和下载 URL;`httpboot-protocol` 中的 +`PROTOCOL_VERSION` 继续标识这条旧路径,`DEVICE_PROTOCOL_VERSION` 标识 v5。 +v5 装载器只广播,不调用任何服务端 HTTP 接口。TGOS 当前依赖已发布 +`httpboot-protocol 0.3.0`;其 v5 设备请求类型在本地定义,两仓用实际 HTTP +契约测试核对,代码交付不依赖另一仓的绝对路径或发布新 crate。 -[virtual_qemu] -enabled = true -qemu_binary = "/usr/bin/qemu-system-x86_64" -ovmf_code = "/usr/share/OVMF/OVMF_CODE_4M.fd" -ovmf_vars = "/usr/share/OVMF/OVMF_VARS_4M.fd" -axloader_efi = "/opt/ostool/BOOTX64.EFI" -runtime_dir = "/var/lib/ostool-server/qemu" -network_namespace = "ostool-qemu" -bridge = "ostool-br0" -tap_pool = ["ostool-tap0", "ostool-tap1"] -memory_mib = 512 -cpus = 2 -``` +### 3.2 隔离测试 -本地网络环境命令需要创建 network namespace、veth、bridge、TAP 和 dnsmasq,因此应以具备 `CAP_NET_ADMIN` 的身份运行: +`ostool-server/scripts/test-axloader-local.py` 启动本地 ostool-server 与 OVMF/QEMU, +在临时目录创建板卡 TOML、服务端配置、OVMF VARS 和真实 FAT 磁盘。管理入口 +使用独立 loopback 端口,关闭测速与系统 TFTP 接管;QEMU `hostfwd` 指向真实 +客户机 TCP4 监听。测试夹具仅转发 QEMU 广播帧并将公告端口换成本地 +`hostfwd` 端口,服务端必须直接 HTTP 请求客户机完成启动与 OTA。 ```bash -ostool-server --config /etc/ostool-server/config.toml virtual-lab up -ostool-server --config /etc/ostool-server/config.toml virtual-lab status -ostool-server --config /etc/ostool-server/config.toml virtual-lab down +cargo build -p ostool-server +python3 ostool-server/scripts/test-axloader-local.py \ + --tgos /home/zhourui/.codex/worktrees/9a24/tgoskits-dev \ + --server-bin target/debug/ostool-server ``` -默认客户机网段为 `10.77.0.0/24`,server 为 `10.77.0.1`,dnsmasq/bridge 为 `10.77.0.254`,DHCP 池为 `10.77.0.100-200`。`up`、`status` 和 `down` 可以重复调用;`up` 检测已存在环境,`down` 忽略已删除资源。 - -管理页面启动虚拟设备后,QEMU 必须通过真实 UDP/HTTP 流程出现在未绑定列表。绑定时三个位置必须引用同一虚拟设备及其 MAC: - -```toml -[network_identity] -mac_address = "02:aa:bb:cc:dd:ee" - -[serial] -baud_rate = 115200 - -[serial.key] -kind = "qemu" -value = "" - -[power_management] -kind = "qemu" -virtual_device_id = "" - -[boot] -kind = "httpboot" -boot_arch = "x86_64" -``` - -`VirtualBoardManager` 持有 QEMU 子进程、TAP、独立 OVMF VARS、QMP socket 和串口 hub。`On`/`Off` 幂等;关闭先发 QMP `quit`,3 秒后仍未退出才终止进程。串口 hub 不随 QEMU 子进程退出,保留最近 64 KiB 输出,使同一 WebSocket 能跨 `Off → On`。新 QEMU 启动会生成新的 loader 注册代次,并从同一活动 Session 重新取得原 `boot_id`。 - -## 验收顺序 - -1. 启动未绑定 QEMU,确认它通过真实发现出现在管理页面。 -2. 人工填写板卡 ID、名称/类型、QEMU 电源和串口,并选择探测 MAC。 -3. 使用 `ostool` 创建 Session、上传 ELF并连接串口,确认下载、摘要校验、handoff 和串口成功标志。 -4. 保持 WebSocket 和 Session,执行虚拟板 `Off → On`,确认新 `registration_id` 取得相同 `boot_id` 并再次 handoff。 -5. 关闭 WebSocket,确认 Session 经 `releasing` 回到 `idle` 且 QEMU 退出。 -6. 新建 Session 再运行一次,全程不修改绑定。 - -实体板按相同顺序验收首次绑定、活动 Session 内重启、释放和新 Session;差异只在电源与串口后端。 +TGOS 单仓用 `cargo xtask axloader test qemu --target x86_64-unknown-uefi` 验证 +同一真实 FAT 映像上的直连启动、镜像升级、坏摘要、短请求、待试复位和持久确认。 +本地测试不调用安装、更新或 systemd 脚本,不连接 runner,也不刷写实体板卡。 +`hostfwd` 证明本地真实 HTTP 反向调用,不证明实体网络广播与反向路由;后者 +属于日后上线前的单独验收。 diff --git a/httpboot-protocol/src/lib.rs b/httpboot-protocol/src/lib.rs index 2039993a..ab55482c 100644 --- a/httpboot-protocol/src/lib.rs +++ b/httpboot-protocol/src/lib.rs @@ -9,6 +9,8 @@ use core::{fmt, str::FromStr}; use alloc::{string::String, vec::Vec}; pub const PROTOCOL_VERSION: u16 = 4; +/// Loader-hosted HTTP protocol. v2-v4 remain valid for server-hosted polling. +pub const DEVICE_PROTOCOL_VERSION: u16 = 5; pub const PREVIOUS_PROTOCOL_VERSION: u16 = 3; pub const LEGACY_PROTOCOL_VERSION: u16 = 2; pub const MAX_HOST_CMDLINE_BYTES: usize = 4095; @@ -167,6 +169,68 @@ pub struct LoaderDiscoveryProbe { pub loader_version: String, } +/// A v5 loader advertises its own HTTP endpoint without registering with a server. +#[cfg(feature = "alloc")] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoaderAnnouncement { + pub protocol_version: u16, + pub mac_address: MacAddress, + pub current_mac_address: MacAddress, + pub arch: BootArch, + pub loader_version: String, + pub boot_epoch: String, + pub http_port: u16, +} + +/// The caller owns the boot ID; the loader owns the current boot epoch. +#[cfg(feature = "alloc")] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceBootJob { + pub boot_id: String, + pub arch: BootArch, + pub image_format: ImageFormat, + pub kernel: DeviceBootImage, + pub initramfs: Option, + pub cmdline: Option, + pub entry_symbol: Option, +} + +#[cfg(feature = "alloc")] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceBootImage { + pub size: u64, + pub sha256: String, +} + +#[cfg(feature = "alloc")] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceBootStatus { + pub boot_id: String, + pub phase: String, + pub kernel_received: bool, + pub initramfs_received: bool, + pub last_error: Option, +} + +#[cfg(feature = "alloc")] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoaderDeviceStatus { + pub protocol_version: u16, + pub boot_epoch: String, + pub mac_address: MacAddress, + pub current_mac_address: MacAddress, + pub arch: BootArch, + pub loader_version: String, + pub hardware: LoaderHardwareInfo, + pub boot: Option, + pub ota: Option, +} + #[cfg(feature = "alloc")] #[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/ostool-server/Cargo.toml b/ostool-server/Cargo.toml index 0c3e75c2..16a371d8 100644 --- a/ostool-server/Cargo.toml +++ b/ostool-server/Cargo.toml @@ -38,6 +38,7 @@ mime_guess = "2" network-interface = "2" nusb = "0.2.3" rust-embed = {version = "8", features = ["interpolate-folder-path", "debug-embed"]} +reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } schemars = {workspace = true, features = ["derive"]} serde = {workspace = true, features = ["derive"]} serde_json = {workspace = true} diff --git a/ostool-server/scripts/test-axloader-local.py b/ostool-server/scripts/test-axloader-local.py new file mode 100644 index 00000000..2830d3e0 --- /dev/null +++ b/ostool-server/scripts/test-axloader-local.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""Isolated QEMU + real ostool-server v5 integration, without host network privileges.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import socket +import struct +import subprocess +import tempfile +import threading +import time +import urllib.error +import urllib.request + +MAC = "02:00:00:00:00:01" + + +def free_port(): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def request(url, method="GET", body=None, headers=None, timeout=12): + if isinstance(body, dict): + body = json.dumps(body).encode() + headers = {**(headers or {}), "Content-Type": "application/json"} + req = urllib.request.Request(url, data=body, headers=headers or {}, method=method) + with urllib.request.urlopen(req, timeout=timeout) as response: + data = response.read() + return json.loads(data) if data else {} + + +def wait_for(label, probe, seconds=110): + end = time.monotonic() + seconds + last = "" + while time.monotonic() < end: + try: + result = probe() + if result: + print("local v5:", label) + return result + except (OSError, ValueError, KeyError, urllib.error.HTTPError) as error: + last = str(error) + time.sleep(0.25) + raise RuntimeError("timed out waiting for {}: {}".format(label, last)) + + +def run(*args, cwd=None): + subprocess.run(args, cwd=cwd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def kernel_elf(): + data = bytearray(0x1002) + data[:4] = b"\x7fELF" + data[4:7] = b"\x02\x01\x01" + struct.pack_into("= 4: + length = struct.unpack_from(">I", frames)[0] + if length < 42 or length > 65535: + frames.clear() + break + if len(frames) < 4 + length: + break + frame = bytes(frames[4:4 + length]) + del frames[:4 + length] + if frame[12:14] != b"\x08\x00" or frame[23] != 17: + continue + ip_header = (frame[14] & 15) * 4 + offset = 14 + ip_header + if len(frame) < offset + 8 or struct.unpack_from(">H", frame, offset + 2)[0] != 2998: + continue + udp_length = struct.unpack_from(">H", frame, offset + 4)[0] + if udp_length < 8 or len(frame) < offset + udp_length: + continue + try: + announcement = json.loads(frame[offset + 8:offset + udp_length].decode()) + except (UnicodeDecodeError, ValueError): + continue + if announcement.get("protocol_version") != 5 or announcement.get("mac_address") != MAC: + continue + # Only the test endpoint changes: device status still comes from real QEMU TCP4. + announcement["http_port"] = guest_port + outbound.sendto(json.dumps(announcement).encode(), ("127.0.0.1", 2998)) + outbound.close() + + +def build_disk(root, tgos, loader): + (root / "A.EFI").write_bytes(loader) + (root / "B.EFI").write_bytes(loader + b"\x01") + shutil.copy2(tgos / "target/x86_64-unknown-uefi/release/axloader-launcher.efi", root / "BOOTX64.EFI") + state = subprocess.run(["python3", str(tgos / "bootloader/axloader/scripts/init-ota-state.py"), + "--stable", "A.EFI", "--trial", "B.EFI", "--output", "."], + cwd=root, check=True, text=True, capture_output=True) + initial_id = state.stdout.strip().removeprefix("first trial update_id=") + disk = root / "esp.img" + run("truncate", "-s", "128M", str(disk)) + run("mkfs.vfat", "-F", "32", "-n", "OSTOOLBOOT", str(disk)) + run("mmd", "-i", str(disk), "::EFI", "::EFI/BOOT", "::EFI/AXLOADER") + run("mcopy", "-i", str(disk), str(root / "BOOTX64.EFI"), "::EFI/BOOT/BOOTX64.EFI") + for filename in ("A.EFI", "B.EFI", "STATE0.BIN", "STATE1.BIN"): + run("mcopy", "-i", str(disk), str(root / filename), "::EFI/AXLOADER/" + filename) + return disk, initial_id + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--tgos", type=Path, required=True) + parser.add_argument("--server-bin", type=Path, required=True) + parser.add_argument("--ovmf-code", type=Path, default=Path("/usr/share/OVMF/OVMF_CODE_4M.fd")) + parser.add_argument("--ovmf-vars", type=Path, default=Path("/usr/share/OVMF/OVMF_VARS_4M.fd")) + args = parser.parse_args() + tgos = args.tgos.resolve() + root = Path(tempfile.mkdtemp(prefix="axloader-v5-local-")) + print("local v5 artifacts:", root, flush=True) + loader = (tgos / "target/x86_64-unknown-uefi/release/axloader.efi").read_bytes() + disk, initial_id = build_disk(root, tgos, loader) + shutil.copy2(args.ovmf_vars, root / "vars.fd") + server_port, device_port, capture_port = free_port(), free_port(), free_port() + data = root / "server-data" + boards = data / "boards" + boards.mkdir(parents=True) + (boards / "qemu-v5.toml").write_text('''id = "qemu-v5" +board_type = "qemu-v5" +disabled = false +[network_identity] +mac_address = "02:00:00:00:00:01" +[serial] +baud_rate = 115200 +[serial.key] +kind = "serial_number" +value = "qemu-v5-local" +[power_management] +kind = "custom" +power_on_cmd = "true" +power_off_cmd = "true" +[boot] +kind = "httpboot" +boot_arch = "x86_64" +''') + config = root / "server.toml" + config.write_text('''listen_addr = "127.0.0.1:{server_port}" +data_dir = "{data}" +board_dir = "{boards}" +dtb_dir = "{data}/dtbs" +[network_test] +enabled = false +[tftp] +provider = "builtin" +enabled = false +root_dir = "{data}/tftp" +bind_addr = "127.0.0.1:6969" +[network] +interface = "lo" +[http_boot] +enabled = true +root_dir = "{data}/http-boot" +public_base_url = "http://127.0.0.1:{server_port}" +[loader_network] +enabled = true +bind_addr = "127.0.0.1:2998" +'''.format(server_port=server_port, data=data, boards=boards)) + capture = socket.socket() + capture.bind(("127.0.0.1", capture_port)) + capture.listen() + stopped = threading.Event() + relay = threading.Thread(target=relay_beacons, args=(capture, device_port, stopped), daemon=True) + relay.start() + children = [] + with (root / "ostool-server.log").open("wb") as server_log: + server = subprocess.Popen([str(args.server_bin.resolve()), "--config", str(config)], + stdout=server_log, stderr=subprocess.STDOUT) + children.append(server) + base = "http://127.0.0.1:{}".format(server_port) + device = "http://127.0.0.1:{}".format(device_port) + def start_guest(index): + out = (root / "qemu-{}.log".format(index)).open("wb") + guest = subprocess.Popen([ + "qemu-system-x86_64", "-m", "512M", "-smp", "1", "-machine", "q35", + "-accel", "kvm", "-cpu", "host", "-display", "none", "-monitor", "none", + "-serial", "stdio", "-netdev", "user,id=user0,hostfwd=tcp:127.0.0.1:{}-:2999".format(device_port), + "-chardev", "socket,id=discovery_capture,host=127.0.0.1,port={},reconnect-ms=100".format(capture_port), + "-object", "filter-mirror,id=discovery_mirror,netdev=user0,queue=rx,outdev=discovery_capture", + "-device", "virtio-net-pci,netdev=user0,mac=" + MAC, + "-drive", "if=pflash,format=raw,readonly=on,file=" + str(args.ovmf_code), + "-drive", "if=pflash,format=raw,file=" + str(root / "vars.fd"), + "-drive", "format=raw,if=ide,file=" + str(disk), + ], stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT) + children.append(guest) + return guest + def stop_guest(guest): + guest.terminate() + try: + guest.wait(timeout=5) + except subprocess.TimeoutExpired: + guest.kill() + guest.wait() + try: + wait_for("local server listening", lambda: request(base + "/api/v1/admin/boards")) + guest = start_guest(1) + trial = wait_for("first B trial", lambda: (lambda status: status if status["ota"]["pending_update_id"] == initial_id else None)(request(device + "/api/v1/status"))) + request(device + "/api/v1/ota/confirm", "POST", {"update_id": initial_id}, {"X-Boot-Epoch": trial["boot_epoch"]}) + wait_for("server discovered v5 device", lambda: (lambda body: body if "qemu-v5" in json.dumps(body) and MAC in json.dumps(body) else None)(request(base + "/api/v1/admin/loader-devices"))) + session = request(base + "/api/v1/sessions", "POST", {"board_type": "qemu-v5", "board_id": "qemu-v5", "required_tags": []}) + session_id = session["session_id"] + image = kernel_elf() + request(base + "/api/v1/sessions/{}/http-boot/kernel".format(session_id), "PUT", image, + {"X-HttpBoot-Arch": "x86_64", "X-HttpBoot-Image-Format": "elf64", + "X-HttpBoot-Remote-Name": "kernel.elf"}) + wait_for("server pushed ELF into QEMU and handed off", lambda: ( + b'ready_to_handoff' in (root / "qemu-1.log").read_bytes() + and b'elf_loaded:' in (root / "qemu-1.log").read_bytes()), 120) + try: + request(base + "/api/v1/sessions/" + session_id, "DELETE") + except urllib.error.HTTPError as error: + if error.code != 404: + raise + wait_for("board released", lambda: (lambda value: value if value["lease_state"] == "idle" else None)( + request(base + "/api/v1/admin/boards/qemu-v5/runtime-status"))) + stop_guest(guest) + guest = start_guest(2) + wait_for("stable B after boot", lambda: (lambda value: value if value["ota"]["active_sha256"] == hashlib.sha256(loader + b"\x01").hexdigest() else None)(request(device + "/api/v1/status"))) + changed = loader + image = request(base + "/api/v1/admin/loader-images", "POST", changed, + {"Content-Length": str(len(changed)), "X-Image-Version": "local-v5"}) + assignment = request(base + "/api/v1/admin/boards/qemu-v5/loader-updates", "POST", + {"image_sha256": image["sha256"]}) + task_id = assignment["update_id"] + server.terminate() + server.wait(timeout=8) + restarted_log = (root / "ostool-server-restarted.log").open("wb") + server = subprocess.Popen([str(args.server_bin.resolve()), "--config", str(config)], + stdout=restarted_log, stderr=subprocess.STDOUT) + children.append(server) + wait_for("server restarted with persistent OTA task", lambda: request(base + "/api/v1/admin/boards")) + wait_for("server pushed and confirmed OTA", lambda: (lambda jobs: jobs if task_id in json.dumps(jobs) and 'succeeded' in json.dumps(jobs) else None)( + request(base + "/api/v1/admin/boards/qemu-v5/loader-updates")), 150) + status = wait_for("confirmed A slot", lambda: (lambda value: value if value["ota"]["active_sha256"] == hashlib.sha256(loader).hexdigest() and value["ota"]["pending_update_id"] is None else None)(request(device + "/api/v1/status"))) + assert status["ota"]["active_sha256"] == hashlib.sha256(loader).hexdigest() + stop_guest(guest) + guest = start_guest(3) + wait_for("confirmed A survives reboot", lambda: (lambda value: value if value["ota"]["active_sha256"] == hashlib.sha256(loader).hexdigest() and value["ota"]["running_sha256"] == value["ota"]["active_sha256"] else None)(request(device + "/api/v1/status"))) + stop_guest(guest) + (root / "result.json").write_text(json.dumps({ + "result": "passed", + "loader_sha256": hashlib.sha256(loader).hexdigest(), + "server_sha256": hashlib.sha256(args.server_bin.read_bytes()).hexdigest(), + "task_id": task_id, + "fat_disk": str(disk), + "guest_logs": [str(root / "qemu-{}.log".format(n)) for n in (1, 2, 3)], + "server_logs": [str(root / "ostool-server.log"), str(root / "ostool-server-restarted.log")], + }, indent=2) + "\n") + print("local v5: real ostool-server, QEMU boot and OTA confirmed across FAT reboots") + finally: + stopped.set() + capture.close() + for child in reversed(children): + if child.poll() is None: + child.terminate() + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + relay.join(timeout=2) + + +if __name__ == "__main__": + main() diff --git a/ostool-server/src/api/router.rs b/ostool-server/src/api/router.rs index 07977e1b..cd2a08fb 100644 --- a/ostool-server/src/api/router.rs +++ b/ostool-server/src/api/router.rs @@ -2755,6 +2755,72 @@ async fn rewrite_board_dtb_references( Ok(()) } +async fn notify_admin_changes( + State(state): State, + request: Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let mutation = + request.method() != axum::http::Method::GET && request.method() != axum::http::Method::HEAD; + let path = request.uri().path().to_owned(); + let response = next.run(request).await; + if mutation { + let topics: &[&str] = if path.contains("/loaders/") { + &["loaders"] + } else if path.contains("/boards") { + &["boards"] + } else if path.contains("/sessions") { + &["sessions", "virtual"] + } else if path.contains("/virtual-devices") { + &["virtual", "serial"] + } else if path.contains("/dtbs") { + &["dtbs"] + } else if path.contains("/tftp") { + &["tftp"] + } else if path.contains("/server-config") { + &["server"] + } else { + &[] + }; + state.admin_events.invalidate(topics); + } + response +} + +pub(crate) async fn admin_topic( + state: &AppState, + topic: &str, +) -> Result { + let extractor = State(state.clone()); + let value = match topic { + "quarantined_boards" => serde_json::to_value(state.board_store.quarantined().await?), + "boards" => serde_json::to_value(list_boards(extractor).await?.0), + "sessions" => serde_json::to_value(list_admin_sessions(extractor).await?.0.sessions), + "loaders" => serde_json::to_value(list_loader_devices(extractor).await?.0), + "ota" => { + Ok(serde_json::json!({ "jobs": state.ota.jobs().await, "images": state.ota.images()? })) + } + "virtual" => serde_json::to_value(list_virtual_devices(extractor).await.0), + "dtbs" => serde_json::to_value(list_dtbs(extractor).await?.0), + "serial" => serde_json::to_value(list_serial_ports().await?.0), + "network" => serde_json::to_value(list_network_interfaces().await?.0), + "server" => serde_json::to_value(get_server_config(extractor).await?.0), + "tftp" => serde_json::to_value(get_tftp_config(extractor).await?.0.tftp), + "tftp_status" => serde_json::to_value(get_tftp_status(extractor).await?.0.status), + "overview" => serde_json::to_value(get_admin_overview(extractor).await?.0), + "power_actions" => serde_json::to_value(state.admin_power.snapshots()), + "runtimes" => { + let mut values = BTreeMap::new(); + for (id, r) in state.board_runtimes.read().await.iter() { + values.insert(id.clone(), serde_json::json!({"lease_state":r.lease_state,"active_session_id":r.active_session_id,"last_release_error":r.last_release_error,"updated_at":r.updated_at})); + } + serde_json::to_value(values) + } + _ => return Err(ApiError::bad_request("unknown admin topic")), + }; + value.map_err(|e| ApiError::internal(e.to_string())) +} + #[cfg(test)] mod tests { use std::future; @@ -5919,69 +5985,3 @@ mod tests { assert_eq!(response.status(), StatusCode::CONFLICT); } } - -async fn notify_admin_changes( - State(state): State, - request: Request, - next: axum::middleware::Next, -) -> axum::response::Response { - let mutation = - request.method() != axum::http::Method::GET && request.method() != axum::http::Method::HEAD; - let path = request.uri().path().to_owned(); - let response = next.run(request).await; - if mutation { - let topics: &[&str] = if path.contains("/loaders/") { - &["loaders"] - } else if path.contains("/boards") { - &["boards"] - } else if path.contains("/sessions") { - &["sessions", "virtual"] - } else if path.contains("/virtual-devices") { - &["virtual", "serial"] - } else if path.contains("/dtbs") { - &["dtbs"] - } else if path.contains("/tftp") { - &["tftp"] - } else if path.contains("/server-config") { - &["server"] - } else { - &[] - }; - state.admin_events.invalidate(topics); - } - response -} - -pub(crate) async fn admin_topic( - state: &AppState, - topic: &str, -) -> Result { - let extractor = State(state.clone()); - let value = match topic { - "quarantined_boards" => serde_json::to_value(state.board_store.quarantined().await?), - "boards" => serde_json::to_value(list_boards(extractor).await?.0), - "sessions" => serde_json::to_value(list_admin_sessions(extractor).await?.0.sessions), - "loaders" => serde_json::to_value(list_loader_devices(extractor).await?.0), - "ota" => { - Ok(serde_json::json!({ "jobs": state.ota.jobs().await, "images": state.ota.images()? })) - } - "virtual" => serde_json::to_value(list_virtual_devices(extractor).await.0), - "dtbs" => serde_json::to_value(list_dtbs(extractor).await?.0), - "serial" => serde_json::to_value(list_serial_ports().await?.0), - "network" => serde_json::to_value(list_network_interfaces().await?.0), - "server" => serde_json::to_value(get_server_config(extractor).await?.0), - "tftp" => serde_json::to_value(get_tftp_config(extractor).await?.0.tftp), - "tftp_status" => serde_json::to_value(get_tftp_status(extractor).await?.0.status), - "overview" => serde_json::to_value(get_admin_overview(extractor).await?.0), - "power_actions" => serde_json::to_value(state.admin_power.snapshots()), - "runtimes" => { - let mut values = BTreeMap::new(); - for (id, r) in state.board_runtimes.read().await.iter() { - values.insert(id.clone(), serde_json::json!({"lease_state":r.lease_state,"active_session_id":r.active_session_id,"last_release_error":r.last_release_error,"updated_at":r.updated_at})); - } - serde_json::to_value(values) - } - _ => return Err(ApiError::bad_request("unknown admin topic")), - }; - value.map_err(|e| ApiError::internal(e.to_string())) -} diff --git a/ostool-server/src/device.rs b/ostool-server/src/device.rs new file mode 100644 index 00000000..ea19f7b8 --- /dev/null +++ b/ostool-server/src/device.rs @@ -0,0 +1,338 @@ +//! v5 devices own the HTTP endpoint. The server only discovers and calls it. + +use std::{net::SocketAddr, time::Duration}; + +use anyhow::{Context, ensure}; +use httpboot_protocol::{ + DEVICE_PROTOCOL_VERSION, DeviceBootImage, DeviceBootJob, LoaderAnnouncement, + LoaderDeviceStatus, LoaderStatusPhase, +}; +use reqwest::{Client, Response}; +use sha2::{Digest, Sha256}; + +use crate::{ + api::router::board_id_for_mac, + config::BootConfig, + ota::{Decision, Phase}, + session::SessionBootCommand, + state::{AppState, BoardLeaseState}, +}; + +pub async fn reconcile( + state: AppState, + announcement: LoaderAnnouncement, + peer: SocketAddr, +) -> anyhow::Result<()> { + ensure!( + announcement.protocol_version == DEVICE_PROTOCOL_VERSION, + "unsupported device protocol" + ); + ensure!(announcement.http_port > 0, "missing device HTTP port"); + let endpoint = format!("http://{}:{}", peer.ip(), announcement.http_port); + let client = Client::builder() + .timeout(Duration::from_secs(300)) + .build()?; + let observed: LoaderDeviceStatus = client + .get(format!("{endpoint}/api/v1/status")) + .timeout(Duration::from_secs(5)) + .send() + .await? + .error_for_status()? + .json() + .await?; + if state + .loader_registry + .accept_announcement(&announcement, &observed, peer.ip().to_string()) + .await + .map_err(|error| anyhow::anyhow!("invalid device announcement: {error:?}"))? + { + return Ok(()); + } + let board_id = { + let boards = state.boards.read().await; + let Some(board_id) = board_id_for_mac(&boards, announcement.mac_address) else { + return Ok(()); + }; + let Some(board) = boards.get(&board_id) else { + return Ok(()); + }; + if board.disabled || !matches!(board.boot, BootConfig::UefiHttp(_)) { + return Ok(()); + } + board_id + }; + let Some(runtime) = state.board_runtime_status(&board_id).await else { + return Ok(()); + }; + let idle = runtime.active_session_id.is_none() + && runtime.lease_state == BoardLeaseState::Idle + && observed.boot.is_none(); + if let Some(ota) = observed.ota.as_ref() { + match state + .ota + .decide(&board_id, announcement.mac_address, ota, idle) + .await? + { + Decision::Update(job) => { + state.admin_events.invalidate(&["ota"]); + let bytes = tokio::fs::read(state.ota.image_path(&job.image.sha256)?).await?; + ensure!( + bytes.len() as u64 == job.image.size && hex_sha256(&bytes) == job.image.sha256, + "OTA image changed in storage" + ); + let response = client + .put(format!("{endpoint}/api/v1/ota/image")) + .header("X-Boot-Epoch", &observed.boot_epoch) + .header("X-Image-Sha256", &job.image.sha256) + .header("X-Update-Source", "server") + .header("X-Update-Id", &job.update_id) + .header("Content-Length", bytes.len()) + .body(bytes) + .send() + .await?; + require_status(response, 202).await?; + state + .ota + .report( + &board_id, + announcement.mac_address, + &job.update_id, + Phase::Staged, + None, + None, + ) + .await?; + state.admin_events.invalidate(&["ota"]); + return Ok(()); + } + Decision::Confirm(update_id) => { + state.admin_events.invalidate(&["ota"]); + let response = client + .post(format!("{endpoint}/api/v1/ota/confirm")) + .header("X-Boot-Epoch", &observed.boot_epoch) + .header("X-Update-Source", "server") + .json(&serde_json::json!({"update_id": update_id})) + .send() + .await?; + require_status(response, 200).await?; + let confirmed: LoaderDeviceStatus = client + .get(format!("{endpoint}/api/v1/status")) + .send() + .await? + .error_for_status()? + .json() + .await?; + let active = confirmed + .ota + .as_ref() + .context("missing confirmed OTA state")?; + ensure!( + active.active_sha256 == job_digest(&state, &board_id).await?, + "confirmed digest mismatch" + ); + state + .ota + .report( + &board_id, + announcement.mac_address, + &update_id, + Phase::Succeeded, + None, + Some(&active.active_sha256), + ) + .await?; + state.admin_events.invalidate(&["ota"]); + return Ok(()); + } + Decision::Wait => return Ok(()), + Decision::Idle => {} + } + if ota.trial { + return Ok(()); + } + } else { + return Ok(()); + } + if runtime.lease_state != BoardLeaseState::Using { + return Ok(()); + } + let Some(session_id) = runtime.active_session_id else { + return Ok(()); + }; + let Some(session) = state.session_state(&session_id).await else { + return Ok(()); + }; + let Some(command) = session.boot_command().await else { + return Ok(()); + }; + if command.arch != observed.arch { + return Ok(()); + } + push_boot( + &state, + &client, + &endpoint, + &observed.boot_epoch, + &session_id, + &command, + &session, + ) + .await +} + +async fn push_boot( + state: &AppState, + client: &Client, + endpoint: &str, + epoch: &str, + session_id: &str, + command: &SessionBootCommand, + session: &crate::session::SessionState, +) -> anyhow::Result<()> { + let manifest = DeviceBootJob { + boot_id: command.boot_id.clone(), + arch: command.arch, + image_format: command.image_format, + kernel: DeviceBootImage { + size: command.kernel_size, + sha256: command.kernel_sha256.clone(), + }, + initramfs: command.initramfs.as_ref().map(|file| DeviceBootImage { + size: file.size, + sha256: file.sha256.clone(), + }), + cmdline: command.cmdline.clone(), + entry_symbol: command.entry_symbol.clone(), + }; + let base = format!("{endpoint}/api/v1/boot/jobs"); + let response = client + .post(&base) + .header("X-Boot-Epoch", epoch) + .json(&manifest) + .send() + .await?; + ensure!( + response.status().as_u16() == 201 || response.status().as_u16() == 200, + "device refused boot job: {}", + response.text().await? + ); + session + .update_loader_status(epoch.into(), &command.boot_id, LoaderStatusPhase::Accepted) + .await + .map_err(|error| anyhow::anyhow!("stale boot session: {error:?}"))?; + let prefix = format!("/boot/sessions/{session_id}/"); + let push = PushContext { + state, + client, + base: &base, + epoch, + session_id, + boot_id: &command.boot_id, + }; + push_file( + &push, + command + .kernel_path + .strip_prefix(&prefix) + .context("kernel path does not belong to session")?, + "kernel", + &manifest.kernel, + ) + .await?; + if let (Some(file), Some(expected)) = (&command.initramfs, &manifest.initramfs) { + push_file( + &push, + file.path + .strip_prefix(&prefix) + .context("initramfs path does not belong to session")?, + "initramfs", + expected, + ) + .await?; + } + session + .update_loader_status(epoch.into(), &command.boot_id, LoaderStatusPhase::Verified) + .await + .map_err(|error| anyhow::anyhow!("stale boot session: {error:?}"))?; + let response = client + .post(format!("{base}/{}/start", command.boot_id)) + .header("X-Boot-Epoch", epoch) + .send() + .await?; + require_status(response, 202).await?; + session + .update_loader_status( + epoch.into(), + &command.boot_id, + LoaderStatusPhase::ReadyToHandoff, + ) + .await + .map_err(|error| anyhow::anyhow!("stale boot session: {error:?}"))?; + Ok(()) +} + +struct PushContext<'a> { + state: &'a AppState, + client: &'a Client, + base: &'a str, + epoch: &'a str, + session_id: &'a str, + boot_id: &'a str, +} + +async fn push_file( + push: &PushContext<'_>, + path: &str, + kind: &str, + image: &DeviceBootImage, +) -> anyhow::Result<()> { + let manager = push.state.tftp_manager.read().await.clone(); + let file = manager + .get_session_file(push.session_id, path) + .await? + .context("session file missing")?; + let bytes = tokio::fs::read(file.disk_path).await?; + ensure!( + bytes.len() as u64 == image.size && hex_sha256(&bytes) == image.sha256, + "published boot file changed on disk" + ); + let response = push + .client + .put(format!("{}/{}/{kind}", push.base, push.boot_id)) + .header("X-Boot-Epoch", push.epoch) + .header("X-Image-Sha256", &image.sha256) + .header("Content-Length", bytes.len()) + .body(bytes) + .send() + .await?; + require_status(response, 200).await +} + +async fn require_status(response: Response, expected: u16) -> anyhow::Result<()> { + let status = response.status(); + ensure!( + status.as_u16() == expected, + "device HTTP {status}: {}", + response.text().await? + ); + Ok(()) +} + +async fn job_digest(state: &AppState, board_id: &str) -> anyhow::Result { + Ok(state + .ota + .job(board_id) + .await + .context("OTA task missing")? + .image + .sha256) +} + +fn hex_sha256(bytes: &[u8]) -> String { + use std::fmt::Write; + let mut result = String::new(); + for byte in Sha256::digest(bytes) { + write!(result, "{byte:02x}").expect("write to String"); + } + result +} diff --git a/ostool-server/src/lib.rs b/ostool-server/src/lib.rs index f5b698c8..fd24be43 100644 --- a/ostool-server/src/lib.rs +++ b/ostool-server/src/lib.rs @@ -7,6 +7,7 @@ pub mod api; pub mod board_pool; pub mod board_store; pub mod config; +pub mod device; pub mod dtb_store; pub mod http_boot; pub mod loader; diff --git a/ostool-server/src/loader.rs b/ostool-server/src/loader.rs index 238257ea..e2d9b7c6 100644 --- a/ostool-server/src/loader.rs +++ b/ostool-server/src/loader.rs @@ -1,11 +1,15 @@ -use std::{collections::BTreeMap, sync::Arc}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; use anyhow::Context; use chrono::{DateTime, Duration, Utc}; use httpboot_protocol::{ - LEGACY_PROTOCOL_VERSION, LoaderDiscoveryOffer, LoaderDiscoveryProbe, LoaderHardwareInfo, - LoaderPollRequest, LoaderStatusReport, MAX_DISCOVERY_DATAGRAM_BYTES, MacAddress, - PREVIOUS_PROTOCOL_VERSION, PROTOCOL_VERSION, + DEVICE_PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION, LoaderAnnouncement, LoaderDeviceStatus, + LoaderDiscoveryOffer, LoaderDiscoveryProbe, LoaderHardwareInfo, LoaderPollRequest, + LoaderStatusReport, MAX_DISCOVERY_DATAGRAM_BYTES, MacAddress, PREVIOUS_PROTOCOL_VERSION, + PROTOCOL_VERSION, }; use serde::Serialize; use tokio::{net::UdpSocket, sync::Mutex, task::JoinHandle}; @@ -76,6 +80,65 @@ pub enum RegistrationError { } impl LoaderRegistry { + pub async fn accept_announcement( + &self, + announcement: &LoaderAnnouncement, + device: &LoaderDeviceStatus, + ip_address: String, + ) -> Result { + if announcement.protocol_version != DEVICE_PROTOCOL_VERSION + || device.protocol_version != DEVICE_PROTOCOL_VERSION + || announcement.mac_address != device.mac_address + || announcement.boot_epoch != device.boot_epoch + || announcement.arch != device.arch + { + return Err(RegistrationError::ProtocolVersion); + } + let now = Utc::now(); + let mut state = self.state.lock().await; + prune(&mut state, now); + let conflict = state + .devices + .get(&device.mac_address) + .is_some_and(|previous| { + (previous.ip_address != ip_address || previous.conflict) + && now - previous.last_seen_at < ONLINE_TTL + }); + if conflict { + if let Some(previous) = state.devices.get_mut(&device.mac_address) { + previous.conflict = true; + } + } else { + state.devices.insert( + device.mac_address, + DeviceRecord { + current_mac_address: device.current_mac_address, + ip_address, + arch: device.arch, + loader_version: device.loader_version.clone(), + hardware: device.hardware.clone(), + last_seen_at: now, + current_registration_id: device.boot_epoch.clone(), + conflict: false, + }, + ); + } + drop(state); + self.events.invalidate(&["loaders"]); + self.deadline_changed.notify_one(); + Ok(conflict) + } + + pub async fn touch_announcement(&self, announcement: &LoaderAnnouncement, ip: &str) { + let mut state = self.state.lock().await; + if let Some(device) = state.devices.get_mut(&announcement.mac_address) + && device.current_registration_id == announcement.boot_epoch + && device.ip_address == ip + { + device.last_seen_at = Utc::now(); + } + self.deadline_changed.notify_one(); + } pub fn new() -> Self { Self { events: crate::admin_events::AdminEvents::default(), @@ -377,7 +440,12 @@ fn prune(state: &mut LoaderRegistryState, now: DateTime) { .map(|mac| (mac, has_live_superseded_report(state, mac, now))) .collect::>(); for (mac, device) in &mut state.devices { - device.conflict = conflicts.get(mac).copied().unwrap_or(false); + let v5_conflict = device.conflict + && !state + .registrations + .contains_key(&device.current_registration_id) + && now - device.last_seen_at < ONLINE_TTL; + device.conflict = v5_conflict || conflicts.get(mac).copied().unwrap_or(false); } } @@ -423,11 +491,33 @@ async fn serve_udp_discovery( socket: UdpSocket, ) -> anyhow::Result<()> { let mut buffer = [0_u8; MAX_DISCOVERY_DATAGRAM_BYTES + 1]; + let in_progress = Arc::new(Mutex::new(BTreeSet::new())); loop { let (length, peer) = socket.recv_from(&mut buffer).await?; if length > MAX_DISCOVERY_DATAGRAM_BYTES { continue; } + if let Ok(announcement) = serde_json::from_slice::(&buffer[..length]) + && announcement.protocol_version == DEVICE_PROTOCOL_VERSION + { + state + .loader_registry + .touch_announcement(&announcement, &peer.ip().to_string()) + .await; + let mac = announcement.mac_address; + if !in_progress.lock().await.insert(mac) { + continue; + } + let active = in_progress.clone(); + let state = state.clone(); + tokio::spawn(async move { + if let Err(error) = crate::device::reconcile(state, announcement, peer).await { + log::warn!("v5 device {} at {}: {error:#}", mac, peer); + } + active.lock().await.remove(&mac); + }); + continue; + } let Ok(probe) = serde_json::from_slice::(&buffer[..length]) else { continue; }; @@ -456,6 +546,59 @@ mod tests { use super::*; use httpboot_protocol::{BootArch, LoaderStatusPhase, PROTOCOL_VERSION}; + #[tokio::test] + async fn v5_announcement_requires_matching_observed_epoch_and_address() { + let registry = LoaderRegistry::new(); + let mac = "02:00:00:00:00:01".parse().unwrap(); + let announcement = LoaderAnnouncement { + protocol_version: DEVICE_PROTOCOL_VERSION, + mac_address: mac, + current_mac_address: mac, + arch: BootArch::X86_64, + loader_version: "test".into(), + boot_epoch: "generation-1".into(), + http_port: 2999, + }; + let mut observed = LoaderDeviceStatus { + protocol_version: DEVICE_PROTOCOL_VERSION, + boot_epoch: "generation-2".into(), + mac_address: mac, + current_mac_address: mac, + arch: BootArch::X86_64, + loader_version: "test".into(), + hardware: LoaderHardwareInfo::default(), + boot: None, + ota: None, + }; + assert!( + registry + .accept_announcement(&announcement, &observed, "127.0.0.1".into()) + .await + .is_err() + ); + assert!(registry.snapshots().await.is_empty()); + observed.boot_epoch = announcement.boot_epoch.clone(); + assert!( + !registry + .accept_announcement(&announcement, &observed, "127.0.0.1".into()) + .await + .unwrap() + ); + assert_eq!( + registry.snapshots().await[0] + .current_registration_id + .as_deref(), + Some("generation-1") + ); + assert!( + registry + .accept_announcement(&announcement, &observed, "127.0.0.2".into()) + .await + .unwrap() + ); + assert!(registry.snapshots().await[0].conflict); + } + fn probe(mac: MacAddress) -> LoaderDiscoveryProbe { LoaderDiscoveryProbe { protocol_version: PROTOCOL_VERSION, From c06bfcb6728ae7a2b9214f3a0089e0b29401dcff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Tue, 29 Sep 2026 12:15:45 +0800 Subject: [PATCH 3/7] fix(ostool-server): address axloader OTA review feedback --- README.en.md | 2 +- README.md | 2 +- docs/api.md | 18 +- docs/axloader-network-control.md | 2 + ostool-server/src/device.rs | 457 ++++++++++++++++++++- ostool-server/src/ota.rs | 86 +++- ostool-server/webui/src/pages/BoardOta.tsx | 7 +- ostool-server/webui/src/utils/ota.test.ts | 17 + ostool-server/webui/src/utils/ota.ts | 7 + 9 files changed, 580 insertions(+), 18 deletions(-) create mode 100644 ostool-server/webui/src/utils/ota.test.ts create mode 100644 ostool-server/webui/src/utils/ota.ts diff --git a/README.en.md b/README.en.md index 051a41f7..7fda8687 100644 --- a/README.en.md +++ b/README.en.md @@ -15,7 +15,7 @@ ## 📖 Project Overview -See [docs/axloader-network-control.md](docs/axloader-network-control.md) for the axloader network control (v3 host boot payloads with v2 compatibility), persistent MAC binding, web administration, and built-in QEMU virtual board design. The complete API contract is documented in [docs/api.md](docs/api.md). +See [docs/axloader-network-control.md](docs/axloader-network-control.md) for axloader network control (legacy v2/v3/v4 compatibility and the v5 device HTTP API), persistent MAC binding, web administration, and local QEMU validation with a real FAT disk image. The complete API contract is documented in [docs/api.md](docs/api.md). The management console at `/admin/` uses React + shadcn/ui. In the new-board form, choose a power module and power it on before selecting a discovered MAC or entering one manually; saving completes the binding. All management pages receive SSE updates and preserve displayed data and drafts across reconnects. See [Admin UI and event protocol](docs/admin-ui.md). At startup, incompatible board TOML files move into `quarantine/` under the board directory with their original contents and diagnostic metadata; valid boards continue to load. diff --git a/README.md b/README.md index 9a0b66e2..7bf7f990 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ## 📖 项目简介 -axloader 网络控制(v3 宿主启动制品及 v2 兼容)、持久 MAC 绑定、Web 管理和内建 QEMU 虚拟板设计见 [docs/axloader-network-control.md](docs/axloader-network-control.md),完整接口契约见 [docs/api.md](docs/api.md)。 +axloader 网络控制(v2/v3/v4 旧协议兼容及 v5 设备 HTTP 接口)、持久 MAC 绑定、Web 管理和本地 QEMU/真实 FAT 磁盘验证设计见 [docs/axloader-network-control.md](docs/axloader-network-control.md),完整接口契约见 [docs/api.md](docs/api.md)。 管理后台位于 `/admin/`,使用 React + shadcn/ui。新建开发板时可先选择电源模块并手动上电,再从实时发现列表选择或手工填写 MAC;保存后完成绑定。全部管理页面采用 SSE 推送更新,断线重连保留页面与编辑草稿。详见 [管理界面与事件协议](docs/admin-ui.md)。 不兼容的板卡 TOML 会在启动时移入板卡目录下的 `quarantine/`,保留原文件和原因,其余有效板卡继续加载。 diff --git a/docs/api.md b/docs/api.md index 82aee804..8c32a824 100644 --- a/docs/api.md +++ b/docs/api.md @@ -670,7 +670,23 @@ Content-Type: application/json 本节定义两种后端共用的开发板服务契约:本地局域网模式由 `ostool-server` 直接提供,认证模式由独立认证后端提供受认证的对应接口。这里覆盖 `ostool-server` 的全部公开、非管理 REST 接口。`ostool` 当前命令会使用会话文件上传;配置宿主 initramfs 时还会调用普通 HTTP Boot 文件上传。它不直接调用会话详情、会话文件列表/查询/删除和显式电源控制;这些仍属于公开 board 服务契约,其中显式电源控制也已有 `BoardServerClient` 方法。 -axloader 协议 v2/v3/v4 使用 `POST /api/v1/loaders/poll`、`POST /api/v1/loaders/status` 和 `GET /api/v1/sessions/{session_id}/loader-status`。poll/status 由 UDP 发现返回的一次性 `registration_id` 关联本次固件启动;状态以 `session_id + boot_id + registration_id` 定位,旧代次迟到上报不能覆盖新代次。v2 loader 仅在没有 `initramfs` 与 `cmdline` 时接收启动,否则得到 `boot_payload_unsupported`;v3 保留原有启动契约;v4 另携带 `ota` 状态并可收到 `update` / `confirm_update`。Session 释放时删除启动清单和 loader 状态,不删除独立持久的 OTA 任务。管理端镜像上传、指派、取消及设备状态路径见 [axloader 网络控制与虚拟板](axloader-network-control.md#装载器升级接口v4)。 +axloader 协议 v2/v3/v4 使用 `POST /api/v1/loaders/poll`、`POST /api/v1/loaders/status` 和 `GET /api/v1/sessions/{session_id}/loader-status`。poll/status 由 UDP 发现返回的一次性 `registration_id` 关联本次固件启动;状态以 `session_id + boot_id + registration_id` 定位,旧代次迟到上报不能覆盖新代次。v2 loader 仅在没有 `initramfs` 与 `cmdline` 时接收启动,否则得到 `boot_payload_unsupported`;v3 保留原有启动契约;v4 另携带 `ota` 状态并可收到 `update` / `confirm_update`。Session 释放时删除启动清单和 loader 状态,不删除独立持久的 OTA 任务。 + +v5 axloader 通过 UDP 广播自身地址,ostool-server 随后调用设备的 HTTP 接口。设备 `POST /api/v1/boot/jobs` 创建事务返回 `201`,相同清单重试返回 `200`,冲突清单返回 `409`;`POST /api/v1/boot/jobs/{id}/start` 和 `PUT /api/v1/ota/image` 接受交接或升级后返回 `202`;`POST /api/v1/ota/confirm` 成功返回 `200`,代次、来源或升级 ID 不匹配返回 `409`。完整设备接口及状态机见 [axloader 网络控制与本地验证](axloader-network-control.md#21-启动事务)和[装载器升级](axloader-network-control.md#22-装载器升级)。设备没有可用 OTA 持久区时,`ota` 状态可以为空;服务端跳过升级,但仍可向已有 Session 推送普通启动事务。 + +装载器镜像库和指派任务使用下列 ostool-server 接口: + +| 方法与路径 | 成功结果 | 主要冲突或校验错误 | +| --- | --- | --- | +| `GET /api/v1/admin/loader-images` | `200`,返回持久镜像元数据列表 | 镜像库读取失败返回 `500` | +| `POST /api/v1/admin/loader-images` | `201`,按请求体保存 EFI 并返回摘要、长度和可选 `X-Image-Version` | 缺少或错误的 `Content-Length`、无效 PE/COFF 或版本返回 `400`,超过 32 MiB 返回 `413` | +| `GET /api/v1/admin/boards/{board_id}/loader-updates` | `200`,返回该板卡当前任务或 `null` | 未知板卡返回 `404` | +| `POST /api/v1/admin/boards/{board_id}/loader-updates` | `201`,创建绑定当前板卡 MAC 的独立升级 ID | 板卡有 Session、已有非终态任务、设备处于待试槽或镜像已激活时返回 `409` | +| `DELETE /api/v1/admin/boards/{board_id}/loader-updates/{update_id}` | `200`,取消任意非终态任务并返回 `cancelled` 任务 | ID 已被替代或任务已经终结返回 `409` | +| `GET /api/v1/loader-updates/{update_id}/image` | `200`,向旧 v4 loader 返回任务对应 EFI | 任务不存在或不可下载返回 `404`,板卡 MAC 已改变返回 `409` | +| `POST /api/v1/loaders/ota-status` | `204`,接受旧 v4 loader 的阶段上报 | 协议版本错误返回 `400`,注册、MAC、任务或阶段不匹配返回 `409` | + +OTA 镜像和任务是独立于 Session 的持久状态。服务端重启后继续使用原升级 ID;取消已激活的任务不会远程改写设备状态,已进入待试槽的设备会在未获确认时按自身 A/B 规则回滚。 ### 查询开发板类型 diff --git a/docs/axloader-network-control.md b/docs/axloader-network-control.md index 87da3472..08078156 100644 --- a/docs/axloader-network-control.md +++ b/docs/axloader-network-control.md @@ -10,6 +10,8 @@ `loader::serve_udp_discovery()` 对 v5 广播调用 `device::reconcile()`,先反向 GET 设备状态,核对 MAC、代次和架构,才从板卡 TOML 中查找当前绑定。 发现不要求服务端响应:直连客户端知道设备 IP 时可独立控制装载器。 +设备没有可写 ESP 或 OTA 状态区时,状态响应中的 `ota` 可以为空;服务端记录 +告警并跳过升级决策,已有 Session 的普通启动推送仍然可用。 广播和 HTTP 没有身份认证。MAC 仅作板卡配置绑定键;报文和镜像的 SHA-256 仅检验一致性,本阶段限定可信隔离实验网。服务器对重复 MAC 且不同来源 IP diff --git a/ostool-server/src/device.rs b/ostool-server/src/device.rs index ea19f7b8..4b074cad 100644 --- a/ostool-server/src/device.rs +++ b/ostool-server/src/device.rs @@ -28,7 +28,8 @@ pub async fn reconcile( "unsupported device protocol" ); ensure!(announcement.http_port > 0, "missing device HTTP port"); - let endpoint = format!("http://{}:{}", peer.ip(), announcement.http_port); + let device_addr = SocketAddr::new(peer.ip(), announcement.http_port); + let endpoint = format!("http://{device_addr}"); let client = Client::builder() .timeout(Duration::from_secs(300)) .build()?; @@ -151,7 +152,11 @@ pub async fn reconcile( return Ok(()); } } else { - return Ok(()); + log::warn!( + "v5 loader {} at {} did not report OTA state; skipping upgrades", + announcement.mac_address, + peer.ip() + ); } if runtime.lease_state != BoardLeaseState::Using { return Ok(()); @@ -336,3 +341,451 @@ fn hex_sha256(bytes: &[u8]) -> String { } result } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::{ + Json, Router, + body::Bytes, + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post, put}, + }; + use httpboot_protocol::{ + BootArch, DEVICE_PROTOCOL_VERSION, DeviceBootJob, ImageFormat, LoaderAnnouncement, + LoaderDeviceStatus, LoaderHardwareInfo, LoaderOtaState, OtaOutcome, OtaSource, + }; + use serde::Deserialize; + use tokio::{net::TcpListener, sync::Mutex}; + + use super::{hex_sha256, reconcile}; + use crate::{ + BoardConfig, BoardNetworkIdentity, BootConfig, BuiltinTftpConfig, CustomPowerManagement, + PowerManagementConfig, ServerConfig, TftpConfig, UefiBootArch, UefiHttpProfile, + build_app_state, ota::Phase, session::SessionBootCommand, state::BoardRuntimeState, + tftp::service::build_tftp_manager, + }; + + #[derive(Clone)] + struct FakeDevice { + status: LoaderDeviceStatus, + accepted_epoch: String, + expected_image: Vec, + update_id: Option, + put_attempts: usize, + confirm_attempts: usize, + allow_confirm: bool, + } + + type FakeState = Arc>; + + #[derive(Clone)] + struct FakeBootDevice { + status: LoaderDeviceStatus, + manifest: Option, + create_attempts: usize, + upload_attempts: usize, + start_attempts: usize, + reject_upload: bool, + kernel: Vec, + } + + type FakeBootState = Arc>; + + async fn get_status(State(device): State) -> Json { + Json(device.lock().await.status.clone()) + } + + async fn put_ota(State(device): State, headers: HeaderMap, body: Bytes) -> Response { + let mut device = device.lock().await; + device.put_attempts += 1; + let header = |name: &'static str| { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + }; + if header("X-Boot-Epoch").as_deref() != Some(device.accepted_epoch.as_str()) { + return (StatusCode::CONFLICT, "stale boot epoch").into_response(); + } + let digest = hex_sha256(&body); + if body.as_ref() != device.expected_image + || header("X-Image-Sha256").as_deref() != Some(digest.as_str()) + || header("X-Update-Source").as_deref() != Some("server") + { + return (StatusCode::BAD_REQUEST, "image mismatch").into_response(); + } + let Some(update_id) = header("X-Update-Id") else { + return (StatusCode::BAD_REQUEST, "missing update id").into_response(); + }; + device.update_id = Some(update_id.clone()); + let ota = device.status.ota.as_mut().unwrap(); + ota.pending_update_id = Some(update_id); + ota.running_sha256 = digest; + ota.trial = true; + ota.source = Some(OtaSource::Server); + StatusCode::ACCEPTED.into_response() + } + + #[derive(Deserialize)] + struct ConfirmRequest { + update_id: String, + } + + async fn confirm_ota( + State(device): State, + headers: HeaderMap, + Json(request): Json, + ) -> Response { + let mut device = device.lock().await; + device.confirm_attempts += 1; + let epoch = headers + .get("X-Boot-Epoch") + .and_then(|value| value.to_str().ok()); + if epoch != Some(device.accepted_epoch.as_str()) + || headers + .get("X-Update-Source") + .and_then(|value| value.to_str().ok()) + != Some("server") + || device.update_id.as_deref() != Some(request.update_id.as_str()) + { + return (StatusCode::CONFLICT, "confirmation mismatch").into_response(); + } + if device.allow_confirm { + let ota = device.status.ota.as_mut().unwrap(); + ota.active_sha256.clone_from(&ota.running_sha256); + ota.pending_update_id = None; + ota.trial = false; + ota.last_update_id = Some(request.update_id); + ota.last_outcome = Some(OtaOutcome::Confirmed); + } + StatusCode::OK.into_response() + } + + async fn get_boot_status(State(device): State) -> Json { + Json(device.lock().await.status.clone()) + } + + async fn create_boot_job( + State(device): State, + headers: HeaderMap, + Json(manifest): Json, + ) -> Response { + let mut device = device.lock().await; + device.create_attempts += 1; + if headers + .get("X-Boot-Epoch") + .and_then(|value| value.to_str().ok()) + != Some(device.status.boot_epoch.as_str()) + { + return (StatusCode::CONFLICT, "stale boot epoch").into_response(); + } + match &device.manifest { + None => { + device.manifest = Some(manifest); + StatusCode::CREATED.into_response() + } + Some(current) if current == &manifest => StatusCode::OK.into_response(), + Some(_) => (StatusCode::CONFLICT, "boot manifest changed").into_response(), + } + } + + async fn put_kernel( + State(device): State, + headers: HeaderMap, + body: Bytes, + ) -> Response { + let mut device = device.lock().await; + device.upload_attempts += 1; + if device.reject_upload { + return (StatusCode::BAD_REQUEST, "digest mismatch").into_response(); + } + let digest = hex_sha256(&body); + if body.as_ref() != device.kernel + || headers + .get("X-Image-Sha256") + .and_then(|value| value.to_str().ok()) + != Some(digest.as_str()) + || headers + .get("X-Boot-Epoch") + .and_then(|value| value.to_str().ok()) + != Some(device.status.boot_epoch.as_str()) + { + return (StatusCode::BAD_REQUEST, "kernel mismatch").into_response(); + } + StatusCode::OK.into_response() + } + + async fn start_boot(State(device): State, headers: HeaderMap) -> Response { + let mut device = device.lock().await; + device.start_attempts += 1; + if headers + .get("X-Boot-Epoch") + .and_then(|value| value.to_str().ok()) + != Some(device.status.boot_epoch.as_str()) + { + return (StatusCode::CONFLICT, "stale boot epoch").into_response(); + } + StatusCode::ACCEPTED.into_response() + } + + fn efi_image() -> Vec { + let mut bytes = vec![0; 512]; + bytes[..2].copy_from_slice(b"MZ"); + bytes[0x3c..0x40].copy_from_slice(&(0x80_u32).to_le_bytes()); + bytes[0x80..0x84].copy_from_slice(b"PE\0\0"); + bytes[0x84..0x86].copy_from_slice(&[0x64, 0x86]); + bytes[0x98..0x9a].copy_from_slice(&[0x0b, 0x02]); + bytes[0xdc..0xde].copy_from_slice(&[10, 0]); + bytes + } + + async fn test_state( + root: &std::path::Path, + mac: httpboot_protocol::MacAddress, + ) -> crate::AppState { + let config_path = root.join("ostool-server.toml"); + let mut config = ServerConfig::default_for_path(&config_path); + config.data_dir = root.join("data"); + config.board_dir = root.join("boards"); + config.dtb_dir = root.join("dtbs"); + config.tftp = TftpConfig::Builtin(BuiltinTftpConfig::default_with_root(root.join("tftp"))); + config.http_boot.root_dir = root.join("http-boot"); + config.virtual_qemu.runtime_dir = root.join("qemu"); + let manager = build_tftp_manager(&config.tftp); + let state = build_app_state(config_path, config, manager).await.unwrap(); + let board = BoardConfig { + id: "board-1".into(), + board_type: "x86_64-uefi-http".into(), + tags: vec![], + serial: None, + power_management: PowerManagementConfig::Custom(CustomPowerManagement { + power_on_cmd: "true".into(), + power_off_cmd: "true".into(), + }), + boot: BootConfig::UefiHttp(UefiHttpProfile { + boot_arch: Some(UefiBootArch::X86_64), + }), + network_identity: Some(BoardNetworkIdentity { mac_address: mac }), + notes: None, + disabled: false, + }; + state.boards.write().await.insert(board.id.clone(), board); + state + .board_runtimes + .write() + .await + .insert("board-1".into(), BoardRuntimeState::default()); + state + } + + fn announcement( + mac: httpboot_protocol::MacAddress, + boot_epoch: &str, + http_port: u16, + ) -> LoaderAnnouncement { + LoaderAnnouncement { + protocol_version: DEVICE_PROTOCOL_VERSION, + mac_address: mac, + current_mac_address: mac, + arch: BootArch::X86_64, + loader_version: "fake-v5".into(), + boot_epoch: boot_epoch.into(), + http_port, + } + } + + #[tokio::test] + async fn reverse_ota_repushes_after_epoch_change_and_retries_confirmation() { + let dir = tempfile::tempdir().unwrap(); + let mac = "02:00:00:00:00:01".parse().unwrap(); + let state = test_state(dir.path(), mac).await; + let image_bytes = efi_image(); + let image = state + .ota + .put_image(&image_bytes, Some("test".into())) + .unwrap(); + let job = state + .ota + .queue("board-1".into(), mac, &image.sha256) + .await + .unwrap(); + let fake = Arc::new(Mutex::new(FakeDevice { + status: LoaderDeviceStatus { + protocol_version: DEVICE_PROTOCOL_VERSION, + boot_epoch: "epoch-1".into(), + mac_address: mac, + current_mac_address: mac, + arch: BootArch::X86_64, + loader_version: "fake-v5".into(), + hardware: LoaderHardwareInfo::default(), + boot: None, + ota: Some(LoaderOtaState { + active_sha256: "11".repeat(32), + running_sha256: "11".repeat(32), + pending_update_id: None, + trial: false, + source: None, + last_update_id: None, + last_outcome: None, + }), + }, + accepted_epoch: "epoch-2".into(), + expected_image: image_bytes, + update_id: None, + put_attempts: 0, + confirm_attempts: 0, + allow_confirm: false, + })); + let app = Router::new() + .route("/api/v1/status", get(get_status)) + .route("/api/v1/ota/image", put(put_ota)) + .route("/api/v1/ota/confirm", post(confirm_ota)) + .with_state(fake.clone()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let peer = "127.0.0.1:40000".parse().unwrap(); + + let error = reconcile(state.clone(), announcement(mac, "epoch-1", port), peer) + .await + .unwrap_err(); + let error = format!("{error:#}"); + assert!( + error.contains("device HTTP 409 Conflict: stale boot epoch"), + "{error}" + ); + assert_eq!( + state.ota.job("board-1").await.unwrap().phase, + Phase::Downloading + ); + + { + let mut fake = fake.lock().await; + fake.status.boot_epoch = "epoch-2".into(); + } + reconcile(state.clone(), announcement(mac, "epoch-2", port), peer) + .await + .unwrap(); + assert_eq!(state.ota.job("board-1").await.unwrap().phase, Phase::Staged); + assert_eq!( + fake.lock().await.update_id.as_deref(), + Some(job.update_id.as_str()) + ); + + let error = reconcile(state.clone(), announcement(mac, "epoch-2", port), peer) + .await + .unwrap_err(); + let error = format!("{error:#}"); + assert!(error.contains("confirmed digest mismatch"), "{error}"); + assert_eq!( + state.ota.job("board-1").await.unwrap().phase, + Phase::Confirming + ); + fake.lock().await.allow_confirm = true; + + reconcile(state.clone(), announcement(mac, "epoch-2", port), peer) + .await + .unwrap(); + assert_eq!( + state.ota.job("board-1").await.unwrap().phase, + Phase::Succeeded + ); + let fake = fake.lock().await; + assert_eq!(fake.put_attempts, 2); + assert_eq!(fake.confirm_attempts, 2); + assert_eq!( + fake.status.ota.as_ref().unwrap().active_sha256, + image.sha256 + ); + server.abort(); + } + + #[tokio::test] + async fn boot_push_retries_device_digest_rejection_without_ota_state() { + let dir = tempfile::tempdir().unwrap(); + let mac = "02:00:00:00:00:02".parse().unwrap(); + let state = test_state(dir.path(), mac).await; + let created = state + .create_session_with_board_id( + "x86_64-uefi-http", + "board-1", + &[], + Some("device-test".into()), + ) + .await + .unwrap(); + let kernel = b"fake ELF64 kernel".to_vec(); + let manager = state.tftp_manager.read().await.clone(); + manager + .put_session_file(&created.id, "kernel.elf", &kernel) + .await + .unwrap(); + let session = state.session_state(&created.id).await.unwrap(); + let boot_id = "boot-1"; + session + .publish_boot_command(SessionBootCommand { + boot_id: boot_id.into(), + kernel_path: format!("/boot/sessions/{}/kernel.elf", created.id), + kernel_size: kernel.len() as u64, + kernel_sha256: hex_sha256(&kernel), + arch: BootArch::X86_64, + image_format: ImageFormat::Elf64, + entry_symbol: None, + initramfs: None, + cmdline: Some("console=ttyS0".into()), + }) + .await; + let fake = Arc::new(Mutex::new(FakeBootDevice { + status: LoaderDeviceStatus { + protocol_version: DEVICE_PROTOCOL_VERSION, + boot_epoch: "boot-epoch".into(), + mac_address: mac, + current_mac_address: mac, + arch: BootArch::X86_64, + loader_version: "fake-v5".into(), + hardware: LoaderHardwareInfo::default(), + boot: None, + ota: None, + }, + manifest: None, + create_attempts: 0, + upload_attempts: 0, + start_attempts: 0, + reject_upload: true, + kernel, + })); + let app = Router::new() + .route("/api/v1/status", get(get_boot_status)) + .route("/api/v1/boot/jobs", post(create_boot_job)) + .route("/api/v1/boot/jobs/{id}/kernel", put(put_kernel)) + .route("/api/v1/boot/jobs/{id}/start", post(start_boot)) + .with_state(fake.clone()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let peer = "127.0.0.1:40001".parse().unwrap(); + let announcement = announcement(mac, "boot-epoch", port); + + let error = reconcile(state.clone(), announcement.clone(), peer) + .await + .unwrap_err(); + let error = format!("{error:#}"); + assert!( + error.contains("device HTTP 400 Bad Request: digest mismatch"), + "{error}" + ); + fake.lock().await.reject_upload = false; + + reconcile(state, announcement, peer).await.unwrap(); + let fake = fake.lock().await; + assert_eq!(fake.create_attempts, 2); + assert_eq!(fake.upload_attempts, 2); + assert_eq!(fake.start_attempts, 1); + assert_eq!(fake.manifest.as_ref().unwrap().boot_id, boot_id); + server.abort(); + } +} diff --git a/ostool-server/src/ota.rs b/ostool-server/src/ota.rs index d2dd20af..8263bc40 100644 --- a/ostool-server/src/ota.rs +++ b/ostool-server/src/ota.rs @@ -244,17 +244,26 @@ impl OtaStore { } pub async fn cancel(&self, board_id: &str, update_id: &str) -> anyhow::Result { + let mut observations = self.observations.lock().await; let mut jobs = self.jobs.lock().await; - let mut updated = jobs.clone(); - let job = updated.get_mut(board_id).context("no OTA job for board")?; + let mut updated_jobs = jobs.clone(); + let job = updated_jobs + .get_mut(board_id) + .context("no OTA job for board")?; ensure!( - job.update_id == update_id && job.phase == Phase::Queued, - "OTA job already activated or superseded" + job.update_id == update_id && !job.phase.is_terminal(), + "OTA job already finished or superseded" ); job.phase = Phase::Cancelled; + job.error = None; let result = job.clone(); - self.save(&updated)?; - *jobs = updated; + + let mut updated_observations = observations.clone(); + updated_observations.remove(board_id); + self.save_observations(&updated_observations)?; + self.save(&updated_jobs)?; + *observations = updated_observations; + *jobs = updated_jobs; Ok(result) } @@ -278,10 +287,7 @@ impl OtaStore { state: ota.clone(), }, ); - atomic_write( - &self.root.join("devices.json"), - &serde_json::to_vec(&updated)?, - )?; + self.save_observations(&updated)?; *observations = updated; } let mut jobs = self.jobs.lock().await; @@ -406,6 +412,16 @@ impl OtaStore { fn save(&self, jobs: &BTreeMap) -> anyhow::Result<()> { atomic_write(&self.root.join("jobs.json"), &serde_json::to_vec(jobs)?) } + + fn save_observations( + &self, + observations: &BTreeMap, + ) -> anyhow::Result<()> { + atomic_write( + &self.root.join("devices.json"), + &serde_json::to_vec(observations)?, + ) + } } fn valid_digest(value: &str) -> bool { @@ -526,4 +542,54 @@ mod tests { .is_err() ); } + + #[tokio::test] + async fn active_update_can_be_cancelled_and_replaced_after_restart() { + let dir = tempfile::tempdir().unwrap(); + let store = OtaStore::open(dir.path()).unwrap(); + let image = store.put_image(&efi_image(), Some("first".into())).unwrap(); + let mac = "02:00:00:00:00:01".parse().unwrap(); + let job = store + .queue("board-1".into(), mac, &image.sha256) + .await + .unwrap(); + let state = LoaderOtaState { + active_sha256: "11".repeat(32), + running_sha256: "11".repeat(32), + pending_update_id: None, + trial: false, + source: None, + last_update_id: None, + last_outcome: None, + }; + assert!(matches!( + store.decide("board-1", mac, &state, true).await.unwrap(), + Decision::Update(_) + )); + assert_eq!( + store.job("board-1").await.unwrap().phase, + Phase::Downloading + ); + + let cancelled = store.cancel("board-1", &job.update_id).await.unwrap(); + assert_eq!(cancelled.phase, Phase::Cancelled); + assert!(!store.observations.lock().await.contains_key("board-1")); + assert!(store.cancel("board-1", &job.update_id).await.is_err()); + + drop(store); + let store = OtaStore::open(dir.path()).unwrap(); + assert_eq!(store.job("board-1").await.unwrap().phase, Phase::Cancelled); + assert!(!store.observations.lock().await.contains_key("board-1")); + let mut replacement = efi_image(); + replacement.push(1); + let replacement = store + .put_image(&replacement, Some("second".into())) + .unwrap(); + assert!( + store + .queue("board-1".into(), mac, &replacement.sha256) + .await + .is_ok() + ); + } } diff --git a/ostool-server/webui/src/pages/BoardOta.tsx b/ostool-server/webui/src/pages/BoardOta.tsx index f5c8dd90..fb769ed3 100644 --- a/ostool-server/webui/src/pages/BoardOta.tsx +++ b/ostool-server/webui/src/pages/BoardOta.tsx @@ -3,6 +3,7 @@ import { api } from "@/api/client"; import { useResource } from "@/api/events"; import { Button } from "@/components/ui/button"; import { Section, SelectField, Notice, ConfirmAction, useAction } from "@/components/forms"; +import { sanitizeImageVersion } from "@/utils/ota"; export function BoardOta({ boardId }: { boardId: string }) { const ota = useResource("ota"); @@ -18,8 +19,8 @@ export function BoardOta({ boardId }: { boardId: string }) {

任务 {job.update_id} · {job.phase} · 镜像 {job.image.version || job.image.sha256.slice(0, 12)}

{job.error && {job.error}} - {pending && job.phase === "queued" && ( - api.cancelLoaderUpdate(boardId, job.update_id)} /> )}
@@ -32,7 +33,7 @@ export function BoardOta({ boardId }: { boardId: string }) { diff --git a/ostool-server/webui/src/utils/ota.test.ts b/ostool-server/webui/src/utils/ota.test.ts new file mode 100644 index 00000000..ddf4e724 --- /dev/null +++ b/ostool-server/webui/src/utils/ota.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeImageVersion } from "./ota"; + +describe("sanitizeImageVersion", () => { + it("keeps server-safe filename characters", () => { + expect(sanitizeImageVersion("axloader-v5.efi")).toBe("axloader-v5.efi"); + }); + + it("normalizes spaces and non-ASCII filename text", () => { + expect(sanitizeImageVersion(" 测试 axloader v5.efi ")).toBe("axloader_v5.efi"); + }); + + it("omits an empty label and enforces the server limit", () => { + expect(sanitizeImageVersion("测试固件")).toBeUndefined(); + expect(sanitizeImageVersion(`${"a".repeat(120)}.efi`)).toHaveLength(96); + }); +}); diff --git a/ostool-server/webui/src/utils/ota.ts b/ostool-server/webui/src/utils/ota.ts new file mode 100644 index 00000000..37f6a22e --- /dev/null +++ b/ostool-server/webui/src/utils/ota.ts @@ -0,0 +1,7 @@ +export function sanitizeImageVersion(fileName: string): string | undefined { + const version = fileName + .replace(/[^A-Za-z0-9._-]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 96); + return version || undefined; +} From 678b2278bcc95623d48fb7413512a8afecc41e1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Tue, 29 Sep 2026 13:07:55 +0800 Subject: [PATCH 4/7] fix(ostool-server): address axloader review feedback --- docs/api.md | 7 +- docs/axloader-network-control.md | 7 +- ostool-server/src/api/ota.rs | 47 ++++-- ostool-server/src/api/router.rs | 158 +++++++++++++++++- ostool-server/src/device.rs | 147 ++++++++++++++-- ostool-server/src/ota.rs | 146 ++++++++++++++-- ostool-server/webui/src/api/client.test.ts | 11 ++ ostool-server/webui/src/api/client.ts | 6 + .../webui/src/pages/BoardOta.test.tsx | 67 ++++++++ ostool-server/webui/src/pages/BoardOta.tsx | 17 +- 10 files changed, 560 insertions(+), 53 deletions(-) create mode 100644 ostool-server/webui/src/pages/BoardOta.test.tsx diff --git a/docs/api.md b/docs/api.md index 8c32a824..8dd0373c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -672,21 +672,22 @@ Content-Type: application/json axloader 协议 v2/v3/v4 使用 `POST /api/v1/loaders/poll`、`POST /api/v1/loaders/status` 和 `GET /api/v1/sessions/{session_id}/loader-status`。poll/status 由 UDP 发现返回的一次性 `registration_id` 关联本次固件启动;状态以 `session_id + boot_id + registration_id` 定位,旧代次迟到上报不能覆盖新代次。v2 loader 仅在没有 `initramfs` 与 `cmdline` 时接收启动,否则得到 `boot_payload_unsupported`;v3 保留原有启动契约;v4 另携带 `ota` 状态并可收到 `update` / `confirm_update`。Session 释放时删除启动清单和 loader 状态,不删除独立持久的 OTA 任务。 -v5 axloader 通过 UDP 广播自身地址,ostool-server 随后调用设备的 HTTP 接口。设备 `POST /api/v1/boot/jobs` 创建事务返回 `201`,相同清单重试返回 `200`,冲突清单返回 `409`;`POST /api/v1/boot/jobs/{id}/start` 和 `PUT /api/v1/ota/image` 接受交接或升级后返回 `202`;`POST /api/v1/ota/confirm` 成功返回 `200`,代次、来源或升级 ID 不匹配返回 `409`。完整设备接口及状态机见 [axloader 网络控制与本地验证](axloader-network-control.md#21-启动事务)和[装载器升级](axloader-network-control.md#22-装载器升级)。设备没有可用 OTA 持久区时,`ota` 状态可以为空;服务端跳过升级,但仍可向已有 Session 推送普通启动事务。 +v5 axloader 通过 UDP 广播自身地址,ostool-server 随后调用设备的 HTTP 接口。设备 `POST /api/v1/boot/jobs` 创建事务返回 `201`,相同清单重试返回 `200`,冲突清单返回 `409`;服务端观察到同代次的其他启动 ID 时会先删除旧事务并只重试创建一次。`POST /api/v1/boot/jobs/{id}/start` 和 `PUT /api/v1/ota/image` 接受交接或升级后返回 `202`;`POST /api/v1/ota/confirm` 成功返回 `200`,代次、来源或升级 ID 不匹配返回 `409`。完整设备接口及状态机见 [axloader 网络控制与本地验证](axloader-network-control.md#21-启动事务)和[装载器升级](axloader-network-control.md#22-装载器升级)。设备没有可用 OTA 持久区时,`ota` 状态可以为空;服务端跳过升级,但仍可向已有 Session 推送普通启动事务。 装载器镜像库和指派任务使用下列 ostool-server 接口: | 方法与路径 | 成功结果 | 主要冲突或校验错误 | | --- | --- | --- | | `GET /api/v1/admin/loader-images` | `200`,返回持久镜像元数据列表 | 镜像库读取失败返回 `500` | -| `POST /api/v1/admin/loader-images` | `201`,按请求体保存 EFI 并返回摘要、长度和可选 `X-Image-Version` | 缺少或错误的 `Content-Length`、无效 PE/COFF 或版本返回 `400`,超过 32 MiB 返回 `413` | +| `POST /api/v1/admin/loader-images` | `201`,按请求体保存 EFI 并返回摘要、长度和可选 `X-Image-Version` | 空请求体、缺少或错误的 `Content-Length`、无效 PE/COFF 或版本返回 `400`,超过 32 MiB 返回 `413` | +| `DELETE /api/v1/admin/loader-images/{sha256}` | `204`,删除未被活动任务引用的 EFI 文件和元数据 | 摘要无效返回 `400`,镜像不存在返回 `404`,仍被非终态任务引用返回 `409` | | `GET /api/v1/admin/boards/{board_id}/loader-updates` | `200`,返回该板卡当前任务或 `null` | 未知板卡返回 `404` | | `POST /api/v1/admin/boards/{board_id}/loader-updates` | `201`,创建绑定当前板卡 MAC 的独立升级 ID | 板卡有 Session、已有非终态任务、设备处于待试槽或镜像已激活时返回 `409` | | `DELETE /api/v1/admin/boards/{board_id}/loader-updates/{update_id}` | `200`,取消任意非终态任务并返回 `cancelled` 任务 | ID 已被替代或任务已经终结返回 `409` | | `GET /api/v1/loader-updates/{update_id}/image` | `200`,向旧 v4 loader 返回任务对应 EFI | 任务不存在或不可下载返回 `404`,板卡 MAC 已改变返回 `409` | | `POST /api/v1/loaders/ota-status` | `204`,接受旧 v4 loader 的阶段上报 | 协议版本错误返回 `400`,注册、MAC、任务或阶段不匹配返回 `409` | -OTA 镜像和任务是独立于 Session 的持久状态。服务端重启后继续使用原升级 ID;取消已激活的任务不会远程改写设备状态,已进入待试槽的设备会在未获确认时按自身 A/B 规则回滚。 +OTA 镜像和任务是独立于 Session 的持久状态。服务端重启后继续使用原升级 ID;取消已激活的任务不会远程改写设备状态,已进入待试槽的设备会在未获确认时按自身 A/B 规则回滚。非终态任务引用的镜像不能删除;删除终态任务曾引用的镜像只回收 EFI 文件和镜像元数据,任务中保存的摘要、版本和长度仍可用于审计。 ### 查询开发板类型 diff --git a/docs/axloader-network-control.md b/docs/axloader-network-control.md index 08078156..37c7e39a 100644 --- a/docs/axloader-network-control.md +++ b/docs/axloader-network-control.md @@ -43,6 +43,8 @@ ostool-server 保留原有 Session、串口 WebSocket、启动清单和板卡租 上传文件后,`device::push_boot()` 从会话存储读取并重新核对长度与摘要, 再调用设备接口;它不把文件 URL 交给装载器。设备在同一 Session 内复位时, 服务器观察新启动代次并按原 `boot_id` 重新推送。串口只承载目标系统输出。 +若同一启动代次仍保留其他 `boot_id`,服务器在创建返回 `409` 后按状态中的旧 ID +删除该事务,再尝试创建一次;第二次仍冲突则停止本次推送,等待后续设备广播。 ## 2. 设备协议 @@ -84,6 +86,9 @@ ostool-server 保留原有 Session、串口 WebSocket、启动清单和板卡租 待试升级时下发。新槽广播后,服务端核对当前板卡绑定、升级 ID、运行摘要与 来源,才调用确认接口;设备持久提交后服务器任务变为成功。服务重启读取独立 于 Session 的镜像库和任务。直连升级不会被服务端自动确认。 +管理端可删除没有被非终态任务引用的镜像;终态任务保留自身的摘要、版本和长度, +因此删除文件不破坏历史任务记录。镜像上传、枚举和删除串行访问同一持久目录, +防止并发上传与删除留下只有元数据或只有 EFI 文件的可见状态。 ## 3. 兼容与本地联调 @@ -107,7 +112,7 @@ v5 装载器只广播,不调用任何服务端 HTTP 接口。TGOS 当前依赖 ```bash cargo build -p ostool-server python3 ostool-server/scripts/test-axloader-local.py \ - --tgos /home/zhourui/.codex/worktrees/9a24/tgoskits-dev \ + --tgos /path/to/tgoskits-dev \ --server-bin target/debug/ostool-server ``` diff --git a/ostool-server/src/api/ota.rs b/ostool-server/src/api/ota.rs index 0d8bf3d8..a9b08410 100644 --- a/ostool-server/src/api/ota.rs +++ b/ostool-server/src/api/ota.rs @@ -12,7 +12,7 @@ use serde::Deserialize; use crate::{ api::{error::ApiError, router::board_id_for_mac}, - ota::{Image, Job, MAX_IMAGE_BYTES, Phase}, + ota::{DeleteImageError, Image, Job, MAX_IMAGE_BYTES, Phase}, state::{AppState, BoardLeaseState}, }; @@ -22,6 +22,10 @@ pub fn routes() -> Router { "/api/v1/admin/loader-images", get(list_images).post(upload_image), ) + .route( + "/api/v1/admin/loader-images/{sha256}", + axum::routing::delete(delete_image), + ) .route( "/api/v1/admin/boards/{board_id}/loader-updates", get(get_job).post(queue_job), @@ -51,7 +55,10 @@ async fn upload_image( .and_then(|value| value.to_str().ok()) .and_then(|value| value.parse::().ok()) .ok_or_else(|| ApiError::bad_request("Content-Length required"))?; - if length == 0 || length > MAX_IMAGE_BYTES { + if length == 0 { + return Err(ApiError::bad_request("empty EFI image")); + } + if length > MAX_IMAGE_BYTES { return Err(ApiError::payload_too_large("EFI image exceeds 32 MiB")); } let version = request @@ -74,6 +81,26 @@ async fn upload_image( Ok((StatusCode::CREATED, Json(image))) } +async fn delete_image( + Path(sha256): Path, + State(state): State, +) -> Result { + state + .ota + .delete_image(&sha256) + .await + .map_err(|error| match error { + DeleteImageError::InvalidDigest => ApiError::bad_request(error.to_string()), + DeleteImageError::NotFound => ApiError::not_found(error.to_string()), + DeleteImageError::InUse => ApiError::conflict(error.to_string()), + DeleteImageError::Inconsistent | DeleteImageError::Io(_) => { + ApiError::internal(error.to_string()) + } + })?; + state.admin_events.invalidate(&["ota"]); + Ok(StatusCode::NO_CONTENT) +} + #[derive(Deserialize)] struct QueueRequest { image_sha256: String, @@ -142,13 +169,7 @@ async fn download_image( .jobs() .await .into_iter() - .find(|job| { - job.update_id == update_id - && !matches!( - job.phase, - Phase::Cancelled | Phase::RolledBack | Phase::Failed - ) - }) + .find(|job| job.update_id == update_id && !job.phase.is_terminal()) .ok_or_else(|| ApiError::not_found("OTA assignment not found"))?; let boards = state.boards.read().await; if board_id_for_mac(&boards, job.mac_address).as_deref() != Some(job.board_id.as_str()) { @@ -156,7 +177,13 @@ async fn download_image( } let bytes = tokio::fs::read(state.ota.image_path(&job.image.sha256)?) .await - .map_err(|error| ApiError::internal(format!("failed to read OTA image: {error}")))?; + .map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + ApiError::not_found("OTA image not found") + } else { + ApiError::internal(format!("failed to read OTA image: {error}")) + } + })?; if bytes.len() as u64 != job.image.size { return Err(ApiError::internal("stored image length changed")); } diff --git a/ostool-server/src/api/router.rs b/ostool-server/src/api/router.rs index cd2a08fb..0770c765 100644 --- a/ostool-server/src/api/router.rs +++ b/ostool-server/src/api/router.rs @@ -2907,6 +2907,17 @@ mod tests { } } + fn efi_image() -> Vec { + let mut bytes = vec![0; 512]; + bytes[..2].copy_from_slice(b"MZ"); + bytes[0x3c..0x40].copy_from_slice(&(0x80_u32).to_le_bytes()); + bytes[0x80..0x84].copy_from_slice(b"PE\0\0"); + bytes[0x84..0x86].copy_from_slice(&[0x64, 0x86]); + bytes[0x98..0x9a].copy_from_slice(&[0x0b, 0x02]); + bytes[0xdc..0xde].copy_from_slice(&[10, 0]); + bytes + } + #[tokio::test] async fn unbound_power_action_requires_neither_board_nor_mac() { let (app, state) = test_router_and_state_with_config(|_| {}).await; @@ -5824,6 +5835,89 @@ mod tests { assert_eq!(value["message"], "board_type must not be empty"); } + #[tokio::test] + async fn loader_image_routes_validate_size_and_delete_unreferenced_image() { + let (app, _) = test_router_and_state_with_config(|_| {}).await; + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/admin/loader-images") + .header(header::CONTENT_LENGTH, 0) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let error: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(error["code"], "bad_request"); + assert_eq!(error["message"], "empty EFI image"); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/admin/loader-images") + .header(header::CONTENT_LENGTH, crate::ota::MAX_IMAGE_BYTES + 1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/api/v1/admin/loader-images/not-a-digest") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let image = efi_image(); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/admin/loader-images") + .header(header::CONTENT_LENGTH, image.len()) + .body(Body::from(image)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + let uploaded: crate::ota::Image = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + let delete = || { + Request::builder() + .method("DELETE") + .uri(format!("/api/v1/admin/loader-images/{}", uploaded.sha256)) + .body(Body::empty()) + .unwrap() + }; + assert_eq!( + app.clone().oneshot(delete()).await.unwrap().status(), + StatusCode::NO_CONTENT + ); + assert_eq!( + app.oneshot(delete()).await.unwrap().status(), + StatusCode::NOT_FOUND + ); + } + #[tokio::test] async fn v4_upgrade_routes_require_a_board_assignment_and_matching_trial() { let (app, state) = test_router_and_state_with_config(|_| {}).await; @@ -5833,13 +5927,7 @@ mod tests { create_board(&app, serde_json::to_value(&board).unwrap()).await, StatusCode::CREATED ); - let mut image = vec![0; 512]; - image[..2].copy_from_slice(b"MZ"); - image[0x3c..0x40].copy_from_slice(&(0x80_u32).to_le_bytes()); - image[0x80..0x84].copy_from_slice(b"PE\0\0"); - image[0x84..0x86].copy_from_slice(&[0x64, 0x86]); - image[0x98..0x9a].copy_from_slice(&[0x0b, 0x02]); - image[0xdc..0xde].copy_from_slice(&[10, 0]); + let image = efi_image(); let response = app .clone() .oneshot( @@ -5871,6 +5959,18 @@ mod tests { let job: crate::ota::Job = serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) .unwrap(); + let response = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/api/v1/admin/loader-images/{}", uploaded.sha256)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); assert_eq!( app.clone().oneshot(queue()).await.unwrap().status(), StatusCode::CONFLICT @@ -5942,7 +6042,7 @@ mod tests { ); let trial = poll.ota.as_mut().unwrap(); trial.pending_update_id = Some(job.update_id.clone()); - trial.running_sha256 = uploaded.sha256; + trial.running_sha256 = uploaded.sha256.clone(); trial.trial = true; trial.source = Some(OtaSource::Direct); let response = app @@ -5978,10 +6078,50 @@ mod tests { assert!( matches!(reply, LoaderPollResponse::ConfirmUpdate { ref update_id, .. } if update_id == &job.update_id) ); - let response = app.oneshot(Request::builder().method("POST").uri("/api/v1/loaders/ota-status") + let response = app.clone().oneshot(Request::builder().method("POST").uri("/api/v1/loaders/ota-status") .header(header::CONTENT_TYPE, "application/json") .body(Body::from(json!({"protocol_version": 4, "registration_id": registration.registration_id, "mac_address": mac, "update_id": job.update_id, "phase": "succeeded"}).to_string())).unwrap()).await.unwrap(); assert_eq!(response.status(), StatusCode::CONFLICT); + + state + .ota + .report( + &board.id, + mac, + &job.update_id, + crate::ota::Phase::Succeeded, + None, + Some(&uploaded.sha256), + ) + .await + .unwrap(); + let response = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/api/v1/admin/loader-images/{}", uploaded.sha256)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + let response = app + .oneshot( + Request::builder() + .uri(format!("/api/v1/loader-updates/{}/image", job.update_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let retained = state.ota.job(&board.id).await.unwrap(); + assert_eq!(retained.phase, crate::ota::Phase::Succeeded); + assert_eq!(retained.image.sha256, uploaded.sha256); + assert_eq!(retained.image.size, uploaded.size); + assert_eq!(retained.image.version, uploaded.version); } } diff --git a/ostool-server/src/device.rs b/ostool-server/src/device.rs index 4b074cad..7433ff65 100644 --- a/ostool-server/src/device.rs +++ b/ostool-server/src/device.rs @@ -177,7 +177,7 @@ pub async fn reconcile( &state, &client, &endpoint, - &observed.boot_epoch, + &observed, &session_id, &command, &session, @@ -189,11 +189,13 @@ async fn push_boot( state: &AppState, client: &Client, endpoint: &str, - epoch: &str, + observed: &LoaderDeviceStatus, session_id: &str, command: &SessionBootCommand, session: &crate::session::SessionState, ) -> anyhow::Result<()> { + let epoch = observed.boot_epoch.as_str(); + let observed_boot_id = observed.boot.as_ref().map(|boot| boot.boot_id.as_str()); let manifest = DeviceBootJob { boot_id: command.boot_id.clone(), arch: command.arch, @@ -210,12 +212,29 @@ async fn push_boot( entry_symbol: command.entry_symbol.clone(), }; let base = format!("{endpoint}/api/v1/boot/jobs"); - let response = client - .post(&base) - .header("X-Boot-Epoch", epoch) - .json(&manifest) - .send() - .await?; + let create = || { + client + .post(&base) + .header("X-Boot-Epoch", epoch) + .json(&manifest) + .send() + }; + let response = create().await?; + let response = if response.status().as_u16() == 409 + && let Some(stale_boot_id) = observed_boot_id + && stale_boot_id != command.boot_id + { + drop(response); + let delete_response = client + .delete(format!("{base}/{stale_boot_id}")) + .header("X-Boot-Epoch", epoch) + .send() + .await?; + require_status(delete_response, 204).await?; + create().await? + } else { + response + }; ensure!( response.status().as_u16() == 201 || response.status().as_u16() == 200, "device refused boot job: {}", @@ -349,14 +368,15 @@ mod tests { use axum::{ Json, Router, body::Bytes, - extract::State, + extract::{Path, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, - routing::{get, post, put}, + routing::{delete, get, post, put}, }; use httpboot_protocol::{ - BootArch, DEVICE_PROTOCOL_VERSION, DeviceBootJob, ImageFormat, LoaderAnnouncement, - LoaderDeviceStatus, LoaderHardwareInfo, LoaderOtaState, OtaOutcome, OtaSource, + BootArch, DEVICE_PROTOCOL_VERSION, DeviceBootJob, DeviceBootStatus, ImageFormat, + LoaderAnnouncement, LoaderDeviceStatus, LoaderHardwareInfo, LoaderOtaState, OtaOutcome, + OtaSource, }; use serde::Deserialize; use tokio::{net::TcpListener, sync::Mutex}; @@ -387,8 +407,10 @@ mod tests { status: LoaderDeviceStatus, manifest: Option, create_attempts: usize, + delete_attempts: usize, upload_attempts: usize, start_attempts: usize, + reject_create_without_manifest: bool, reject_upload: bool, kernel: Vec, } @@ -485,6 +507,16 @@ mod tests { } match &device.manifest { None => { + if device.reject_create_without_manifest { + return (StatusCode::CONFLICT, "create still conflicts").into_response(); + } + device.status.boot = Some(DeviceBootStatus { + boot_id: manifest.boot_id.clone(), + phase: "accepted".into(), + kernel_received: false, + initramfs_received: manifest.initramfs.is_none(), + last_error: None, + }); device.manifest = Some(manifest); StatusCode::CREATED.into_response() } @@ -493,6 +525,33 @@ mod tests { } } + async fn delete_boot_job( + State(device): State, + Path(id): Path, + headers: HeaderMap, + ) -> Response { + let mut device = device.lock().await; + device.delete_attempts += 1; + if headers + .get("X-Boot-Epoch") + .and_then(|value| value.to_str().ok()) + != Some(device.status.boot_epoch.as_str()) + { + return (StatusCode::CONFLICT, "stale boot epoch").into_response(); + } + if device + .manifest + .as_ref() + .map(|manifest| manifest.boot_id.as_str()) + != Some(id.as_str()) + { + return StatusCode::NOT_FOUND.into_response(); + } + device.manifest = None; + device.status.boot = None; + StatusCode::NO_CONTENT.into_response() + } + async fn put_kernel( State(device): State, headers: HeaderMap, @@ -501,6 +560,8 @@ mod tests { let mut device = device.lock().await; device.upload_attempts += 1; if device.reject_upload { + device.manifest = None; + device.status.boot = None; return (StatusCode::BAD_REQUEST, "digest mismatch").into_response(); } let digest = hex_sha256(&body); @@ -705,7 +766,7 @@ mod tests { } #[tokio::test] - async fn boot_push_retries_device_digest_rejection_without_ota_state() { + async fn boot_push_replaces_stale_job_and_only_retries_create_once() { let dir = tempfile::tempdir().unwrap(); let mac = "02:00:00:00:00:02".parse().unwrap(); let state = test_state(dir.path(), mac).await; @@ -739,6 +800,18 @@ mod tests { cmdline: Some("console=ttyS0".into()), }) .await; + let stale_manifest = DeviceBootJob { + boot_id: "stale-boot".into(), + arch: BootArch::X86_64, + image_format: ImageFormat::Elf64, + kernel: httpboot_protocol::DeviceBootImage { + size: 1, + sha256: "00".repeat(32), + }, + initramfs: None, + cmdline: None, + entry_symbol: None, + }; let fake = Arc::new(Mutex::new(FakeBootDevice { status: LoaderDeviceStatus { protocol_version: DEVICE_PROTOCOL_VERSION, @@ -748,19 +821,28 @@ mod tests { arch: BootArch::X86_64, loader_version: "fake-v5".into(), hardware: LoaderHardwareInfo::default(), - boot: None, + boot: Some(DeviceBootStatus { + boot_id: stale_manifest.boot_id.clone(), + phase: "accepted".into(), + kernel_received: false, + initramfs_received: true, + last_error: None, + }), ota: None, }, - manifest: None, + manifest: Some(stale_manifest), create_attempts: 0, + delete_attempts: 0, upload_attempts: 0, start_attempts: 0, + reject_create_without_manifest: false, reject_upload: true, kernel, })); let app = Router::new() .route("/api/v1/status", get(get_boot_status)) .route("/api/v1/boot/jobs", post(create_boot_job)) + .route("/api/v1/boot/jobs/{id}", delete(delete_boot_job)) .route("/api/v1/boot/jobs/{id}/kernel", put(put_kernel)) .route("/api/v1/boot/jobs/{id}/start", post(start_boot)) .with_state(fake.clone()); @@ -780,12 +862,41 @@ mod tests { ); fake.lock().await.reject_upload = false; - reconcile(state, announcement, peer).await.unwrap(); + reconcile(state.clone(), announcement.clone(), peer) + .await + .unwrap(); + { + let fake = fake.lock().await; + assert_eq!(fake.create_attempts, 3); + assert_eq!(fake.delete_attempts, 1); + assert_eq!(fake.upload_attempts, 2); + assert_eq!(fake.start_attempts, 1); + assert_eq!(fake.manifest.as_ref().unwrap().boot_id, boot_id); + } + + session + .publish_boot_command(SessionBootCommand { + boot_id: "boot-2".into(), + kernel_path: format!("/boot/sessions/{}/kernel.elf", created.id), + kernel_size: b"fake ELF64 kernel".len() as u64, + kernel_sha256: hex_sha256(b"fake ELF64 kernel"), + arch: BootArch::X86_64, + image_format: ImageFormat::Elf64, + entry_symbol: None, + initramfs: None, + cmdline: Some("console=ttyS0".into()), + }) + .await; + fake.lock().await.reject_create_without_manifest = true; + let error = reconcile(state, announcement, peer).await.unwrap_err(); + let error = format!("{error:#}"); + assert!(error.contains("device refused boot job: create still conflicts")); let fake = fake.lock().await; - assert_eq!(fake.create_attempts, 2); + assert_eq!(fake.create_attempts, 5); + assert_eq!(fake.delete_attempts, 2); assert_eq!(fake.upload_attempts, 2); assert_eq!(fake.start_attempts, 1); - assert_eq!(fake.manifest.as_ref().unwrap().boot_id, boot_id); + assert!(fake.manifest.is_none()); server.abort(); } } diff --git a/ostool-server/src/ota.rs b/ostool-server/src/ota.rs index 8263bc40..549861b6 100644 --- a/ostool-server/src/ota.rs +++ b/ostool-server/src/ota.rs @@ -5,10 +5,10 @@ use std::{ fs, io::Write, path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, Mutex as StdMutex, MutexGuard as StdMutexGuard}, }; -use anyhow::{Context, ensure}; +use anyhow::{Context, anyhow, ensure}; use httpboot_protocol::{LoaderOtaState, OtaOutcome, OtaSource}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -37,7 +37,7 @@ pub enum Phase { } impl Phase { - fn is_terminal(self) -> bool { + pub(crate) fn is_terminal(self) -> bool { matches!( self, Self::Succeeded | Self::RolledBack | Self::Failed | Self::Cancelled @@ -63,9 +63,24 @@ pub enum Decision { Wait, } +#[derive(Debug, thiserror::Error)] +pub enum DeleteImageError { + #[error("invalid image digest")] + InvalidDigest, + #[error("OTA image not found")] + NotFound, + #[error("OTA image is referenced by an active assignment")] + InUse, + #[error("OTA image storage is inconsistent")] + Inconsistent, + #[error("failed to update OTA image storage: {0}")] + Io(#[from] std::io::Error), +} + #[derive(Clone)] pub struct OtaStore { root: PathBuf, + image_io: Arc>, jobs: Arc>>, observations: Arc>>, } @@ -93,6 +108,7 @@ impl OtaStore { }; Ok(Self { root, + image_io: Arc::new(StdMutex::new(())), jobs: Arc::new(Mutex::new(jobs)), observations: Arc::new(Mutex::new(observations)), }) @@ -104,6 +120,11 @@ impl OtaStore { } pub fn image(&self, digest: &str) -> anyhow::Result { + let _guard = self.lock_image_io()?; + self.image_unlocked(digest) + } + + fn image_unlocked(&self, digest: &str) -> anyhow::Result { ensure!(valid_digest(digest), "invalid image digest"); let bytes = fs::read(self.root.join("images").join(format!("{digest}.json")))?; let image: Image = serde_json::from_slice(&bytes)?; @@ -115,6 +136,7 @@ impl OtaStore { } pub fn images(&self) -> anyhow::Result> { + let _guard = self.lock_image_io()?; let mut images = Vec::new(); for entry in fs::read_dir(self.root.join("images"))? { let entry = entry?; @@ -123,7 +145,7 @@ impl OtaStore { .to_str() .and_then(|name| name.strip_suffix(".json")) { - images.push(self.image(name)?); + images.push(self.image_unlocked(name)?); } } images.sort_by(|a, b| a.sha256.cmp(&b.sha256)); @@ -176,6 +198,7 @@ impl OtaStore { size: bytes.len() as u64, version, }; + let _guard = self.lock_image_io()?; let path = self.image_path(&sha256)?; if !path.exists() { atomic_write(&path, bytes)?; @@ -192,6 +215,43 @@ impl OtaStore { Ok(image) } + pub async fn delete_image(&self, digest: &str) -> Result<(), DeleteImageError> { + if !valid_digest(digest) { + return Err(DeleteImageError::InvalidDigest); + } + let jobs = self.jobs.lock().await; + if jobs + .values() + .any(|job| job.image.sha256 == digest && !job.phase.is_terminal()) + { + return Err(DeleteImageError::InUse); + } + let _guard = self + .image_io + .lock() + .map_err(|_| DeleteImageError::Inconsistent)?; + let directory = self.root.join("images"); + let metadata = directory.join(format!("{digest}.json")); + let image = directory.join(format!("{digest}.efi")); + if !metadata.exists() && !image.exists() { + return Err(DeleteImageError::NotFound); + } + if metadata.is_file() { + fs::remove_file(&metadata)?; + fs::File::open(&directory)?.sync_all()?; + } else if metadata.exists() { + return Err(DeleteImageError::Inconsistent); + } + if image.is_file() { + fs::remove_file(&image)?; + fs::File::open(&directory)?.sync_all()?; + } else if image.exists() { + return Err(DeleteImageError::Inconsistent); + } + drop(jobs); + Ok(()) + } + pub async fn jobs(&self) -> Vec { self.jobs.lock().await.values().cloned().collect() } @@ -206,8 +266,14 @@ impl OtaStore { mac_address: httpboot_protocol::MacAddress, digest: &str, ) -> anyhow::Result { - let image = self.image(digest)?; let observations = self.observations.lock().await; + let mut jobs = self.jobs.lock().await; + ensure!( + jobs.get(&board_id) + .is_none_or(|job| job.phase.is_terminal()), + "board already has an OTA job" + ); + let image = self.image(digest)?; if let Some(device) = observations .get(&board_id) .filter(|device| device.mac_address == mac_address) @@ -221,12 +287,6 @@ impl OtaStore { "this image is already the active loader" ); } - let mut jobs = self.jobs.lock().await; - ensure!( - jobs.get(&board_id) - .is_none_or(|job| job.phase.is_terminal()), - "board already has an OTA job" - ); let job = Job { board_id: board_id.clone(), mac_address, @@ -422,6 +482,12 @@ impl OtaStore { &serde_json::to_vec(observations)?, ) } + + fn lock_image_io(&self) -> anyhow::Result> { + self.image_io + .lock() + .map_err(|_| anyhow!("OTA image storage lock poisoned")) + } } fn valid_digest(value: &str) -> bool { @@ -592,4 +658,62 @@ mod tests { .is_ok() ); } + + #[tokio::test] + async fn image_delete_rejects_active_assignment_and_survives_restart() { + let dir = tempfile::tempdir().unwrap(); + let store = OtaStore::open(dir.path()).unwrap(); + let image = store + .put_image(&efi_image(), Some("delete-me".into())) + .unwrap(); + let mac = "02:00:00:00:00:01".parse().unwrap(); + let job = store + .queue("board-1".into(), mac, &image.sha256) + .await + .unwrap(); + let image_path = store.image_path(&image.sha256).unwrap(); + let metadata_path = store + .root + .join("images") + .join(format!("{}.json", image.sha256)); + assert!(image_path.is_file()); + assert!(metadata_path.is_file()); + + assert!(matches!( + store.delete_image(&image.sha256).await, + Err(DeleteImageError::InUse) + )); + store.cancel("board-1", &job.update_id).await.unwrap(); + store.delete_image(&image.sha256).await.unwrap(); + assert!(store.images().unwrap().is_empty()); + assert!(!image_path.exists()); + assert!(!metadata_path.exists()); + let retained = store.job("board-1").await.unwrap(); + assert_eq!(retained.image.sha256, image.sha256); + assert_eq!(retained.image.size, image.size); + assert_eq!(retained.image.version, image.version); + assert!(matches!( + store.delete_image(&image.sha256).await, + Err(DeleteImageError::NotFound) + )); + + store + .put_image(&efi_image(), Some("delete-me".into())) + .unwrap(); + fs::remove_file(&metadata_path).unwrap(); + store.delete_image(&image.sha256).await.unwrap(); + assert!(!image_path.exists()); + assert!(!metadata_path.exists()); + + drop(store); + let store = OtaStore::open(dir.path()).unwrap(); + assert!(store.images().unwrap().is_empty()); + let retained = store.job("board-1").await.unwrap(); + assert_eq!(retained.phase, Phase::Cancelled); + assert_eq!(retained.image.sha256, image.sha256); + assert_eq!(retained.image.size, image.size); + assert_eq!(retained.image.version, image.version); + assert!(!image_path.exists()); + assert!(!metadata_path.exists()); + } } diff --git a/ostool-server/webui/src/api/client.test.ts b/ostool-server/webui/src/api/client.test.ts index bc89a629..b4189808 100644 --- a/ostool-server/webui/src/api/client.test.ts +++ b/ostool-server/webui/src/api/client.test.ts @@ -20,6 +20,17 @@ describe("api client", () => { await expect(api.deleteSession("demo-session")).resolves.toBeUndefined(); }); + it("deletes a loader image using its encoded digest path", async () => { + const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetch); + + await expect(api.deleteLoaderImage("digest/value")).resolves.toBeUndefined(); + expect(fetch).toHaveBeenCalledWith( + "/api/v1/admin/loader-images/digest%2Fvalue", + expect.objectContaining({ method: "DELETE" }), + ); + }); + it("reports a service connection hint when fetch fails", async () => { vi.stubGlobal( "fetch", diff --git a/ostool-server/webui/src/api/client.ts b/ostool-server/webui/src/api/client.ts index 477981b8..ab7aa0e1 100644 --- a/ostool-server/webui/src/api/client.ts +++ b/ostool-server/webui/src/api/client.ts @@ -71,6 +71,12 @@ export const api = { body: file, }); }, + deleteLoaderImage(imageSha256: string) { + return request( + `/api/v1/admin/loader-images/${encodeURIComponent(imageSha256)}`, + { method: "DELETE" }, + ); + }, queueLoaderUpdate(boardId: string, imageSha256: string) { return request( `/api/v1/admin/boards/${encodeURIComponent(boardId)}/loader-updates`, diff --git a/ostool-server/webui/src/pages/BoardOta.test.tsx b/ostool-server/webui/src/pages/BoardOta.test.tsx new file mode 100644 index 00000000..e0db3117 --- /dev/null +++ b/ostool-server/webui/src/pages/BoardOta.test.tsx @@ -0,0 +1,67 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; + +import { api } from "@/api/client"; +import { admin } from "@/api/events"; +import type { LoaderOtaResources, LoaderUpdateJob } from "@/types/api"; +import { BoardOta } from "./BoardOta"; + +const image = { + sha256: "11".repeat(32), + size: 512, + version: "test-loader", +}; + +function applyOta(job: LoaderUpdateJob) { + const ota: LoaderOtaResources = { images: [image], jobs: [job] }; + admin.apply({ + epoch: crypto.randomUUID(), + revision: 1, + kind: "snapshot", + data: { ota }, + }); +} + +function job(phase: LoaderUpdateJob["phase"]): LoaderUpdateJob { + return { + board_id: "other-board", + mac_address: "02:00:00:00:00:01", + update_id: "update-1", + image, + phase, + error: null, + }; +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +it("disables deletion while any active assignment references the image", async () => { + applyOta(job("downloading")); + render(); + + await userEvent.setup().selectOptions( + screen.getByLabelText("待下发镜像"), + image.sha256, + ); + expect(screen.getByRole("button", { name: "删除镜像" })).toBeDisabled(); +}); + +it("deletes an image referenced only by a terminal assignment", async () => { + applyOta(job("succeeded")); + const remove = vi.spyOn(api, "deleteLoaderImage").mockResolvedValue(undefined); + render(); + const user = userEvent.setup(); + const select = screen.getByLabelText("待下发镜像"); + + await user.selectOptions(select, image.sha256); + await user.click(screen.getByRole("button", { name: "删除镜像" })); + await user.click(screen.getByRole("button", { name: "确认" })); + + await waitFor(() => expect(remove).toHaveBeenCalledWith(image.sha256)); + await waitFor(() => expect(select).toHaveValue("")); +}); diff --git a/ostool-server/webui/src/pages/BoardOta.tsx b/ostool-server/webui/src/pages/BoardOta.tsx index fb769ed3..c5e4c69f 100644 --- a/ostool-server/webui/src/pages/BoardOta.tsx +++ b/ostool-server/webui/src/pages/BoardOta.tsx @@ -5,6 +5,8 @@ import { Button } from "@/components/ui/button"; import { Section, SelectField, Notice, ConfirmAction, useAction } from "@/components/forms"; import { sanitizeImageVersion } from "@/utils/ota"; +const terminalPhases = ["succeeded", "rolled_back", "failed", "cancelled"]; + export function BoardOta({ boardId }: { boardId: string }) { const ota = useResource("ota"); const [selected, setSelected] = useState(""); @@ -12,7 +14,11 @@ export function BoardOta({ boardId }: { boardId: string }) { const upload = useAction(); const assign = useAction(); const job = ota?.jobs.find((entry) => entry.board_id === boardId); - const pending = job && !["succeeded", "rolled_back", "failed", "cancelled"].includes(job.phase); + const pending = job && !terminalPhases.includes(job.phase); + const selectedInUse = ota?.jobs.some( + (entry) => + entry.image.sha256 === selected && !terminalPhases.includes(entry.phase), + ); return (
{job && ( @@ -45,6 +51,15 @@ export function BoardOta({ boardId }: { boardId: string }) { label: `${image.version || image.sha256.slice(0, 12)} · ${(image.size / 1024 / 1024).toFixed(2)} MiB`, })), ]} /> + { + await api.deleteLoaderImage(selected); + setSelected(""); + }} + /> Date: Tue, 29 Sep 2026 15:45:50 +0800 Subject: [PATCH 7/7] fix(ostool-server): address remaining OTA review --- README.en.md | 2 +- README.md | 2 +- docs/api.md | 25 +- docs/axloader-network-control.md | 88 ++++- ostool-server/src/api/ota.rs | 8 +- ostool-server/src/api/router.rs | 9 +- ostool-server/src/device.rs | 1 + ostool-server/src/ota.rs | 351 +++++++++++------- .../webui/src/pages/BoardEditor.test.tsx | 29 ++ ostool-server/webui/src/pages/BoardEditor.tsx | 6 +- 10 files changed, 367 insertions(+), 154 deletions(-) diff --git a/README.en.md b/README.en.md index df14c207..a4320b75 100644 --- a/README.en.md +++ b/README.en.md @@ -15,7 +15,7 @@ ## 📖 Project Overview -See [docs/axloader-network-control.md](docs/axloader-network-control.md) for axloader network control (legacy v2/v3/v4 compatibility and the v5 device HTTP API), persistent MAC binding, web administration, and local QEMU validation with a real FAT disk image. The complete API contract is documented in [docs/api.md](docs/api.md). +See [docs/axloader-network-control.md](docs/axloader-network-control.md) for axloader network control (legacy v2/v3/v4 compatibility and the v5 device HTTP API), persistent MAC binding, web administration, built-in QEMU virtual boards, and local validation with a real FAT disk image. The complete API contract is documented in [docs/api.md](docs/api.md). The management console at `/admin/` uses React + shadcn/ui. In the new-board form, choose a power module and power it on before selecting a discovered MAC or entering one manually; saving completes the binding. All management pages receive SSE updates and preserve displayed data and drafts across reconnects. See [Admin UI and event protocol](docs/admin-ui.md). At startup, incompatible board TOML files move into `quarantine/` under the board directory with their original contents and diagnostic metadata; valid boards continue to load. diff --git a/README.md b/README.md index 527f60c1..4eed166b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ## 📖 项目简介 -axloader 网络控制(v2/v3/v4 旧协议兼容及 v5 设备 HTTP 接口)、持久 MAC 绑定、Web 管理和本地 QEMU/真实 FAT 磁盘验证设计见 [docs/axloader-network-control.md](docs/axloader-network-control.md),完整接口契约见 [docs/api.md](docs/api.md)。 +axloader 网络控制(v2/v3/v4 旧协议兼容及 v5 设备 HTTP 接口)、持久 MAC 绑定、Web 管理、内建 QEMU 虚拟板和本地真实 FAT 磁盘验证设计见 [docs/axloader-network-control.md](docs/axloader-network-control.md),完整接口契约见 [docs/api.md](docs/api.md)。 管理后台位于 `/admin/`,使用 React + shadcn/ui。新建开发板时可先选择电源模块并手动上电,再从实时发现列表选择或手工填写 MAC;保存后完成绑定。全部管理页面采用 SSE 推送更新,断线重连保留页面与编辑草稿。详见 [管理界面与事件协议](docs/admin-ui.md)。 不兼容的板卡 TOML 会在启动时移入板卡目录下的 `quarantine/`,保留原文件和原因,其余有效板卡继续加载。 diff --git a/docs/api.md b/docs/api.md index c1a89880..95bfbaab 100644 --- a/docs/api.md +++ b/docs/api.md @@ -469,7 +469,25 @@ GET /api/v1/admin/loader-devices 返回当前内存探测表,包括永久/当前 MAC、IP、架构、loader 版本、SMBIOS Type 1 摘要、最近出现时间、在线状态、冲突状态、当前注册代次和实时解析出的 `bound_board_id`。10 秒未上报视为离线,记录保留 24 小时;绑定关系始终从板卡 TOML 按 MAC 计算,不单独持久化。Web UI 只为 `bound_board_id = null` 的设备提供“创建配置”。 -内建 QEMU 默认关闭。启用后可管理真实 QEMU 进程: +内建 QEMU 默认关闭。启用时在服务端配置中提供实际 QEMU、OVMF 和 axloader +产物路径,并声明隔离网络及 TAP 池: + +```toml +[virtual_qemu] +enabled = true +qemu_binary = "/usr/bin/qemu-system-x86_64" +ovmf_code = "/usr/share/OVMF/OVMF_CODE_4M.fd" +ovmf_vars = "/usr/share/OVMF/OVMF_VARS_4M.fd" +axloader_efi = "/opt/ostool/BOOTX64.EFI" +runtime_dir = "/var/lib/ostool-server/qemu" +network_namespace = "ostool-qemu" +bridge = "ostool-br0" +tap_pool = ["ostool-tap0", "ostool-tap1"] +memory_mib = 512 +cpus = 2 +``` + +启用后可管理真实 QEMU 进程: ```http GET /api/v1/admin/virtual-devices @@ -492,6 +510,7 @@ ostool-server --config .ostool-server.toml virtual-lab down ``` 默认创建独立 network namespace、bridge、veth、dnsmasq 和当前用户拥有的 TAP 池,客户机网段为 `10.77.0.0/24`,服务端地址为 `10.77.0.1`。该操作需要 Linux `CAP_NET_ADMIN`。 +完整绑定约束与逐步验收流程见 [内建 QEMU 虚拟板](axloader-network-control.md#3-内建-qemu-虚拟板)。 ### DTB 管理 @@ -682,12 +701,12 @@ v5 axloader 通过 UDP 广播自身地址,ostool-server 随后调用设备的 | `POST /api/v1/admin/loader-images` | `201`,按请求体保存 EFI 并返回摘要、长度和可选 `X-Image-Version` | 空请求体、缺少或错误的 `Content-Length`、无效 PE/COFF 或版本返回 `400`,超过 32 MiB 返回 `413` | | `DELETE /api/v1/admin/loader-images/{sha256}` | `204`,删除未被活动任务引用的 EFI 文件和元数据 | 摘要无效返回 `400`,镜像不存在返回 `404`,仍被非终态任务引用返回 `409` | | `GET /api/v1/admin/boards/{board_id}/loader-updates` | `200`,返回该板卡当前任务或 `null` | 未知板卡返回 `404` | -| `POST /api/v1/admin/boards/{board_id}/loader-updates` | `201`,为 x86_64 UEFI HTTP 板卡创建绑定当前 MAC 的独立升级 ID | 架构或启动类型不支持、板卡有 Session、已有非终态任务、设备处于待试槽或镜像已激活时返回 `409` | +| `POST /api/v1/admin/boards/{board_id}/loader-updates` | `201`,为 x86_64 UEFI HTTP 板卡创建绑定当前 MAC 的独立升级 ID;省略 `boot_arch` 时按 `x86_64` 处理 | 架构或启动类型不支持、板卡有 Session、已有非终态任务、设备处于待试槽或镜像已激活时返回 `409` | | `DELETE /api/v1/admin/boards/{board_id}/loader-updates/{update_id}` | `200`,取消任意非终态任务并返回 `cancelled` 任务 | ID 已被替代或任务已经终结返回 `409` | | `GET /api/v1/loader-updates/{update_id}/image` | `200`,向旧 v4 loader 返回任务对应 EFI | 任务不存在或不可下载返回 `404`,板卡 MAC 已改变返回 `409` | | `POST /api/v1/loaders/ota-status` | `204`,接受旧 v4 loader 的阶段上报 | 协议版本错误返回 `400`,注册、MAC、任务或阶段不匹配返回 `409` | -OTA 镜像和任务是独立于 Session 的持久状态。服务端重启后继续使用原升级 ID;同一阶段的回报可安全重试。v5 设备镜像投递失败会持久记录错误和次数,连续三次失败后任务进入 `failed` 并停止重发。取消已激活的任务不会远程改写设备状态,已进入待试槽的设备会在未获确认时按自身 A/B 规则回滚。非终态任务引用的镜像不能删除;删除终态任务曾引用的镜像只回收 EFI 文件和镜像元数据,任务中保存的摘要、版本和长度仍可用于审计。 +OTA 镜像和任务是独立于 Session 的持久状态。服务端重启后继续使用原升级 ID;同一阶段的回报可安全重试。空闲检查只限制新镜像下发;若设备已运行与服务端任务匹配的待试槽,即使板卡已经进入活动 Session,服务端也会先确认该槽,使设备随后可以接受该 Session 的启动事务。v5 设备镜像投递失败会持久记录错误和次数,连续三次失败后任务进入 `failed` 并停止重发。取消已激活的任务不会远程改写设备状态,已进入待试槽的设备会在未获确认时按自身 A/B 规则回滚。非终态任务引用的镜像不能删除;删除终态任务曾引用的镜像只回收 EFI 文件和镜像元数据,任务中保存的摘要、版本和长度仍可用于审计。 ### 查询开发板类型 diff --git a/docs/axloader-network-control.md b/docs/axloader-network-control.md index 0b22d349..7b297b8a 100644 --- a/docs/axloader-network-control.md +++ b/docs/axloader-network-control.md @@ -86,18 +86,92 @@ ostool-server 保留原有 Session、串口 WebSocket、启动清单和板卡租 直连上传默认生成 ID,且来源为 `direct`。服务端指派传入持久任务 ID, 以及 `X-Update-Source: server`;`OtaStore::decide()` 只在板卡空闲且非其他 待试升级时下发。新槽广播后,服务端核对当前板卡绑定、升级 ID、运行摘要与 -来源,才调用确认接口;设备持久提交后服务器任务变为成功。服务重启读取独立 +来源,才调用确认接口;此确认不受板卡租约状态限制,活动 Session 可以在确认 +解锁后继续启动。设备持久提交后服务器任务变为成功。服务重启读取独立 于 Session 的镜像库和任务。直连升级不会被服务端自动确认。 -服务端只向配置为 x86_64 UEFI HTTP 的板卡指派当前 AMD64 EFI 镜像。设备拒绝 +服务端只向配置为 x86_64 UEFI HTTP 的板卡指派当前 AMD64 EFI 镜像;配置省略 +`boot_arch` 时沿用现有 CLI 语义,按 `x86_64` 处理。设备拒绝 镜像或传输失败时,任务持久记录错误和投递次数;连续三次失败后进入 `failed`, 不再重复传输完整镜像。同一阶段的设备回报按幂等请求处理。 管理端可删除没有被非终态任务引用的镜像;终态任务保留自身的摘要、版本和长度, -因此删除文件不破坏历史任务记录。镜像上传、枚举和删除串行访问同一持久目录, -防止并发上传与删除留下只有元数据或只有 EFI 文件的可见状态。 +因此删除文件不破坏历史任务记录。镜像上传和删除通过同一目录门闩串行,元数据 +列表在启动时载入内存并只在增删成功后更新;这既避免 SSE 快照重复扫描目录,也 +防止并发上传、删除或指派留下只有元数据、只有 EFI 文件或悬空任务的可见状态。 + +## 3. 内建 QEMU 虚拟板 + +虚拟板默认关闭,当前固定使用 `x86_64 + OVMF + q35 + TCG`。示例服务端配置: + +```toml +[loader_network] +enabled = true +bind_addr = "0.0.0.0:2998" +public_base_url = "http://10.77.0.1:2999" + +[virtual_qemu] +enabled = true +qemu_binary = "/usr/bin/qemu-system-x86_64" +ovmf_code = "/usr/share/OVMF/OVMF_CODE_4M.fd" +ovmf_vars = "/usr/share/OVMF/OVMF_VARS_4M.fd" +axloader_efi = "/opt/ostool/BOOTX64.EFI" +runtime_dir = "/var/lib/ostool-server/qemu" +network_namespace = "ostool-qemu" +bridge = "ostool-br0" +tap_pool = ["ostool-tap0", "ostool-tap1"] +memory_mib = 512 +cpus = 2 +``` + +相对路径按配置文件目录解析。`virtual-lab` 需要创建 network namespace、veth、 +bridge、TAP 和 dnsmasq,应以具备 Linux `CAP_NET_ADMIN` 的身份运行: + +```bash +ostool-server --config /etc/ostool-server/config.toml virtual-lab up +ostool-server --config /etc/ostool-server/config.toml virtual-lab status +ostool-server --config /etc/ostool-server/config.toml virtual-lab down +``` + +默认客户机网段为 `10.77.0.0/24`,服务端地址为 `10.77.0.1`,dnsmasq/bridge +地址为 `10.77.0.254`,DHCP 池为 `10.77.0.100-200`。三个子命令均可重复调用。 +管理页面启动虚拟设备后,QEMU 必须通过真实 UDP 广播和设备 HTTP 接口出现在 +未绑定列表。绑定时 MAC、串口和电源配置必须引用同一个虚拟设备: + +```toml +[network_identity] +mac_address = "02:aa:bb:cc:dd:ee" + +[serial] +baud_rate = 115200 + +[serial.key] +kind = "qemu" +value = "" + +[power_management] +kind = "qemu" +virtual_device_id = "" + +[boot] +kind = "httpboot" +boot_arch = "x86_64" +``` + +`VirtualBoardManager` 持有 QEMU 子进程、TAP、独立 OVMF VARS、QMP socket 和 +串口 hub。`On`/`Off` 幂等;关闭先发送 QMP `quit`,超时后才终止进程。串口 +hub 保留最近 64 KiB 输出并跨 QEMU 重启,新实例广播新的启动代次。 + +验收顺序如下: + +1. 启动未绑定 QEMU,确认它通过真实发现出现在管理页面。 +2. 填写板卡 ID、类型、QEMU 电源和串口,并选择探测到的 MAC。 +3. 使用 `ostool` 创建 Session、上传内核及可选 initramfs/cmdline,确认设备 HTTP 交接和内核串口标志。 +4. 保持 WebSocket 和 Session,执行虚拟板 `Off -> On`,确认新启动代次重新取得相同启动事务并再次交接。 +5. 关闭 WebSocket,确认 Session 经 `releasing` 回到 `idle` 且 QEMU 退出。 +6. 新建 Session 再运行一次,全程不修改绑定。 -## 3. 兼容与本地联调 +## 4. 兼容与本地联调 -### 3.1 兼容边界 +### 4.1 兼容边界 ostool-server 继续接受旧装载器 v2/v3/v4 的 UDP Offer、 `POST /api/v1/loaders/poll`、状态上报和下载 URL;`httpboot-protocol` 中的 @@ -109,7 +183,7 @@ v5 装载器只广播,不调用任何服务端 HTTP 接口。TGOS 当前依赖 `httpboot-protocol 0.3.0`;其 v5 设备请求类型在本地定义,两仓用实际 HTTP 契约测试核对,代码交付不依赖另一仓的绝对路径或发布新 crate。 -### 3.2 隔离测试 +### 4.2 隔离测试 `ostool-server/scripts/test-axloader-local.py` 启动本地 ostool-server 与 OVMF/QEMU, 在临时目录创建板卡 TOML、服务端配置、OVMF VARS 和真实 FAT 磁盘。管理入口 diff --git a/ostool-server/src/api/ota.rs b/ostool-server/src/api/ota.rs index 7030f832..8dc47f6b 100644 --- a/ostool-server/src/api/ota.rs +++ b/ostool-server/src/api/ota.rs @@ -43,7 +43,7 @@ pub fn routes() -> Router { } async fn list_images(State(state): State) -> Result>, ApiError> { - Ok(Json(state.ota.images()?)) + Ok(Json(state.ota.images().await?)) } async fn upload_image( @@ -77,6 +77,7 @@ async fn upload_image( let image = state .ota .put_image(&body, version) + .await .map_err(|error| ApiError::bad_request(format!("{error:#}")))?; state.admin_events.invalidate(&["ota"]); Ok((StatusCode::CREATED, Json(image))) @@ -119,7 +120,10 @@ async fn queue_job( if !matches!( &board.boot, BootConfig::UefiHttp(profile) - if profile.boot_arch.as_ref() == Some(&UefiBootArch::X86_64) + if profile + .boot_arch + .as_ref() + .is_none_or(|arch| arch == &UefiBootArch::X86_64) ) { return Err(ApiError::conflict( "loader OTA requires an x86_64 UEFI HTTP board", diff --git a/ostool-server/src/api/router.rs b/ostool-server/src/api/router.rs index 5fdf649c..9b0b788b 100644 --- a/ostool-server/src/api/router.rs +++ b/ostool-server/src/api/router.rs @@ -2797,9 +2797,9 @@ pub(crate) async fn admin_topic( "boards" => serde_json::to_value(list_boards(extractor).await?.0), "sessions" => serde_json::to_value(list_admin_sessions(extractor).await?.0.sessions), "loaders" => serde_json::to_value(list_loader_devices(extractor).await?.0), - "ota" => { - Ok(serde_json::json!({ "jobs": state.ota.jobs().await, "images": state.ota.images()? })) - } + "ota" => Ok( + serde_json::json!({ "jobs": state.ota.jobs().await, "images": state.ota.images().await? }), + ), "virtual" => serde_json::to_value(list_virtual_devices(extractor).await.0), "dtbs" => serde_json::to_value(list_dtbs(extractor).await?.0), "serial" => serde_json::to_value(list_serial_ports().await?.0), @@ -5936,7 +5936,8 @@ mod tests { #[tokio::test] async fn v4_upgrade_routes_require_a_board_assignment_and_matching_trial() { let (app, state) = test_router_and_state_with_config(|_| {}).await; - let board = sample_httpboot_board("ota-board"); + let mut board = sample_httpboot_board("ota-board"); + board.boot = BootConfig::UefiHttp(UefiHttpProfile { boot_arch: None }); let mac = board.network_identity.as_ref().unwrap().mac_address; assert_eq!( create_board(&app, serde_json::to_value(&board).unwrap()).await, diff --git a/ostool-server/src/device.rs b/ostool-server/src/device.rs index a2ac3947..e1eae87b 100644 --- a/ostool-server/src/device.rs +++ b/ostool-server/src/device.rs @@ -757,6 +757,7 @@ mod tests { let image = state .ota .put_image(&image_bytes, Some("test".into())) + .await .unwrap(); let job = state .ota diff --git a/ostool-server/src/ota.rs b/ostool-server/src/ota.rs index 5e4cee80..4e5b1d63 100644 --- a/ostool-server/src/ota.rs +++ b/ostool-server/src/ota.rs @@ -5,10 +5,10 @@ use std::{ fs, io::Write, path::{Path, PathBuf}, - sync::{Arc, Mutex as StdMutex, MutexGuard as StdMutexGuard}, + sync::Arc, }; -use anyhow::{Context, anyhow, ensure}; +use anyhow::{Context, ensure}; use httpboot_protocol::{LoaderOtaState, OtaOutcome, OtaSource}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -83,7 +83,7 @@ pub enum DeleteImageError { #[derive(Clone)] pub struct OtaStore { root: PathBuf, - image_io: Arc>, + image_catalog: Arc>>, jobs: Arc>>, observations: Arc>>, } @@ -109,9 +109,13 @@ impl OtaStore { Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(), Err(error) => return Err(error.into()), }; + let images = read_images(&root)? + .into_iter() + .map(|image| (image.sha256.clone(), image)) + .collect(); Ok(Self { root, - image_io: Arc::new(StdMutex::new(())), + image_catalog: Arc::new(Mutex::new(images)), jobs: Arc::new(Mutex::new(jobs)), observations: Arc::new(Mutex::new(observations)), }) @@ -122,99 +126,22 @@ impl OtaStore { Ok(self.root.join("images").join(format!("{digest}.efi"))) } - pub fn image(&self, digest: &str) -> anyhow::Result { - let _guard = self.lock_image_io()?; - self.image_unlocked(digest) + pub async fn images(&self) -> anyhow::Result> { + Ok(self.image_catalog.lock().await.values().cloned().collect()) } - fn image_unlocked(&self, digest: &str) -> anyhow::Result { - ensure!(valid_digest(digest), "invalid image digest"); - let bytes = fs::read(self.root.join("images").join(format!("{digest}.json")))?; - let image: Image = serde_json::from_slice(&bytes)?; - ensure!( - image.sha256 == digest && self.image_path(digest)?.is_file(), - "missing OTA image" - ); - Ok(image) - } - - pub fn images(&self) -> anyhow::Result> { - let _guard = self.lock_image_io()?; - let mut images = Vec::new(); - for entry in fs::read_dir(self.root.join("images"))? { - let entry = entry?; - if let Some(name) = entry - .file_name() - .to_str() - .and_then(|name| name.strip_suffix(".json")) - { - images.push(self.image_unlocked(name)?); - } - } - images.sort_by(|a, b| a.sha256.cmp(&b.sha256)); - Ok(images) - } - - pub fn put_image(&self, bytes: &[u8], version: Option) -> anyhow::Result { + pub async fn put_image(&self, bytes: &[u8], version: Option) -> anyhow::Result { ensure!( !bytes.is_empty() && bytes.len() <= MAX_IMAGE_BYTES, "invalid EFI image size" ); - // DOS header, PE signature, AMD64 machine and EFI application subsystem. - ensure!( - bytes.len() >= 0x40 && &bytes[..2] == b"MZ", - "invalid DOS header" - ); - let pe_offset = u32::from_le_bytes(bytes[0x3c..0x40].try_into()?) as usize; - ensure!( - pe_offset - .checked_add(94) - .is_some_and(|end| end <= bytes.len()), - "invalid PE offset" - ); - ensure!( - &bytes[pe_offset..pe_offset + 4] == b"PE\0\0", - "invalid PE header" - ); - ensure!( - bytes[pe_offset + 4..pe_offset + 6] == [0x64, 0x86], - "unsupported EFI architecture" - ); - let optional = pe_offset + 24; - ensure!( - bytes[optional..optional + 2] == [0x0b, 0x02], - "EFI image must be PE32+" - ); - ensure!( - bytes[optional + 68..optional + 70] == [10, 0], - "EFI image must be an application" - ); - if let Some(label) = &version { - ensure!( - label.len() <= 96 && label.bytes().all(|byte| byte.is_ascii_graphic()), - "invalid image version" - ); - } - let sha256 = format!("{:x}", Sha256::digest(bytes)); - let image = Image { - sha256: sha256.clone(), - size: bytes.len() as u64, - version, - }; - let _guard = self.lock_image_io()?; - let path = self.image_path(&sha256)?; - if !path.exists() { - atomic_write(&path, bytes)?; - } else { - ensure!( - Sha256::digest(fs::read(&path)?).as_slice() == Sha256::digest(bytes).as_slice(), - "existing image digest mismatch" - ); - } - atomic_write( - &self.root.join("images").join(format!("{sha256}.json")), - &serde_json::to_vec(&image)?, - )?; + let bytes = bytes.to_vec(); + let root = self.root.clone(); + let mut images = self.image_catalog.lock().await; + let image = tokio::task::spawn_blocking(move || write_image(&root, &bytes, version)) + .await + .context("OTA image write task failed")??; + images.insert(image.sha256.clone(), image.clone()); Ok(image) } @@ -222,37 +149,27 @@ impl OtaStore { if !valid_digest(digest) { return Err(DeleteImageError::InvalidDigest); } - let jobs = self.jobs.lock().await; - if jobs - .values() - .any(|job| job.image.sha256 == digest && !job.phase.is_terminal()) + let mut images = self.image_catalog.lock().await; { - return Err(DeleteImageError::InUse); - } - let _guard = self - .image_io - .lock() - .map_err(|_| DeleteImageError::Inconsistent)?; - let directory = self.root.join("images"); - let metadata = directory.join(format!("{digest}.json")); - let image = directory.join(format!("{digest}.efi")); - if !metadata.exists() && !image.exists() { - return Err(DeleteImageError::NotFound); - } - if metadata.is_file() { - fs::remove_file(&metadata)?; - fs::File::open(&directory)?.sync_all()?; - } else if metadata.exists() { - return Err(DeleteImageError::Inconsistent); + let jobs = self.jobs.lock().await; + if jobs + .values() + .any(|job| job.image.sha256 == digest && !job.phase.is_terminal()) + { + return Err(DeleteImageError::InUse); + } } - if image.is_file() { - fs::remove_file(&image)?; - fs::File::open(&directory)?.sync_all()?; - } else if image.exists() { - return Err(DeleteImageError::Inconsistent); + let root = self.root.clone(); + let digest = digest.to_owned(); + let remove_digest = digest.clone(); + let (result, still_valid) = + tokio::task::spawn_blocking(move || remove_image(&root, &remove_digest)) + .await + .map_err(|_| DeleteImageError::Inconsistent)?; + if !still_valid { + images.remove(digest.as_str()); } - drop(jobs); - Ok(()) + result } pub async fn jobs(&self) -> Vec { @@ -269,6 +186,10 @@ impl OtaStore { mac_address: httpboot_protocol::MacAddress, digest: &str, ) -> anyhow::Result { + // Keep deletion and assignment mutually exclusive while the cached + // catalog entry is promoted into a persistent job. + let images = self.image_catalog.lock().await; + let image = images.get(digest).context("missing OTA image")?.clone(); let observations = self.observations.lock().await; let mut jobs = self.jobs.lock().await; ensure!( @@ -276,7 +197,6 @@ impl OtaStore { .is_none_or(|job| job.phase.is_terminal()), "board already has an OTA job" ); - let image = self.image(digest)?; if let Some(device) = observations .get(&board_id) .filter(|device| device.mac_address == mac_address) @@ -380,8 +300,7 @@ impl OtaStore { } } let decision = if ota.trial { - if board_idle - && ota.source == Some(OtaSource::Server) + if ota.source == Some(OtaSource::Server) && ota.pending_update_id.as_deref() == Some(&job.update_id) && ota.running_sha256 == job.image.sha256 && !job.phase.is_terminal() @@ -522,12 +441,128 @@ impl OtaStore { &serde_json::to_vec(observations)?, ) } +} + +fn image_path(root: &Path, digest: &str) -> PathBuf { + root.join("images").join(format!("{digest}.efi")) +} + +fn read_image(root: &Path, digest: &str) -> anyhow::Result { + ensure!(valid_digest(digest), "invalid image digest"); + let bytes = fs::read(root.join("images").join(format!("{digest}.json")))?; + let image: Image = serde_json::from_slice(&bytes)?; + ensure!( + image.sha256 == digest && image_path(root, digest).is_file(), + "missing OTA image" + ); + Ok(image) +} - fn lock_image_io(&self) -> anyhow::Result> { - self.image_io - .lock() - .map_err(|_| anyhow!("OTA image storage lock poisoned")) +fn read_images(root: &Path) -> anyhow::Result> { + let mut images = Vec::new(); + for entry in fs::read_dir(root.join("images"))? { + let entry = entry?; + if let Some(name) = entry + .file_name() + .to_str() + .and_then(|name| name.strip_suffix(".json")) + { + match read_image(root, name) { + Ok(image) => images.push(image), + Err(error) => { + log::warn!("ignoring inconsistent OTA image metadata `{name}`: {error:#}") + } + } + } } + images.sort_by(|a, b| a.sha256.cmp(&b.sha256)); + Ok(images) +} + +fn write_image(root: &Path, bytes: &[u8], version: Option) -> anyhow::Result { + // DOS header, PE signature, AMD64 machine and EFI application subsystem. + ensure!( + bytes.len() >= 0x40 && &bytes[..2] == b"MZ", + "invalid DOS header" + ); + let pe_offset = u32::from_le_bytes(bytes[0x3c..0x40].try_into()?) as usize; + ensure!( + pe_offset + .checked_add(94) + .is_some_and(|end| end <= bytes.len()), + "invalid PE offset" + ); + ensure!( + &bytes[pe_offset..pe_offset + 4] == b"PE\0\0", + "invalid PE header" + ); + ensure!( + bytes[pe_offset + 4..pe_offset + 6] == [0x64, 0x86], + "unsupported EFI architecture" + ); + let optional = pe_offset + 24; + ensure!( + bytes[optional..optional + 2] == [0x0b, 0x02], + "EFI image must be PE32+" + ); + ensure!( + bytes[optional + 68..optional + 70] == [10, 0], + "EFI image must be an application" + ); + if let Some(label) = &version { + ensure!( + label.len() <= 96 && label.bytes().all(|byte| byte.is_ascii_graphic()), + "invalid image version" + ); + } + let sha256 = format!("{:x}", Sha256::digest(bytes)); + let image = Image { + sha256: sha256.clone(), + size: bytes.len() as u64, + version, + }; + let path = image_path(root, &sha256); + if !path.exists() { + atomic_write(&path, bytes)?; + } else { + ensure!( + format!("{:x}", Sha256::digest(fs::read(&path)?)) == sha256, + "existing image digest mismatch" + ); + } + atomic_write( + &root.join("images").join(format!("{sha256}.json")), + &serde_json::to_vec(&image)?, + )?; + Ok(image) +} + +fn remove_image(root: &Path, digest: &str) -> (Result<(), DeleteImageError>, bool) { + let result = remove_image_files(root, digest); + let still_valid = read_image(root, digest).is_ok(); + (result, still_valid) +} + +fn remove_image_files(root: &Path, digest: &str) -> Result<(), DeleteImageError> { + let directory = root.join("images"); + let metadata = directory.join(format!("{digest}.json")); + let image = image_path(root, digest); + if !metadata.exists() && !image.exists() { + return Err(DeleteImageError::NotFound); + } + if metadata.is_file() { + fs::remove_file(&metadata)?; + fs::File::open(&directory)?.sync_all()?; + } else if metadata.exists() { + return Err(DeleteImageError::Inconsistent); + } + if image.is_file() { + fs::remove_file(&image)?; + fs::File::open(&directory)?.sync_all()?; + } else if image.exists() { + return Err(DeleteImageError::Inconsistent); + } + Ok(()) } fn valid_digest(value: &str) -> bool { @@ -573,7 +608,10 @@ mod tests { async fn duplicate_phase_reports_are_idempotent() { let dir = tempfile::tempdir().unwrap(); let store = OtaStore::open(dir.path()).unwrap(); - let image = store.put_image(&efi_image(), Some("first".into())).unwrap(); + let image = store + .put_image(&efi_image(), Some("first".into())) + .await + .unwrap(); let mac = "02:00:00:00:00:01".parse().unwrap(); let job = store .queue("board-1".into(), mac, &image.sha256) @@ -605,6 +643,7 @@ mod tests { replacement.push(1); let image = store .put_image(&replacement, Some("second".into())) + .await .unwrap(); let job = store .queue("board-1".into(), mac, &image.sha256) @@ -643,7 +682,7 @@ mod tests { async fn delivery_failures_are_persisted_and_bounded() { let dir = tempfile::tempdir().unwrap(); let store = OtaStore::open(dir.path()).unwrap(); - let image = store.put_image(&efi_image(), None).unwrap(); + let image = store.put_image(&efi_image(), None).await.unwrap(); let mac = "02:00:00:00:00:01".parse().unwrap(); let job = store .queue("board-1".into(), mac, &image.sha256) @@ -695,10 +734,13 @@ mod tests { } #[tokio::test] - async fn update_survives_server_restart_and_only_confirms_its_own_trial() { + async fn update_survives_restart_and_confirms_its_own_trial_while_board_is_busy() { let dir = tempfile::tempdir().unwrap(); let store = OtaStore::open(dir.path()).unwrap(); - let image = store.put_image(&efi_image(), Some("test".into())).unwrap(); + let image = store + .put_image(&efi_image(), Some("test".into())) + .await + .unwrap(); let mac = "02:00:00:00:00:01".parse().unwrap(); let job = store .queue("board-1".into(), mac, &image.sha256) @@ -739,7 +781,7 @@ mod tests { )); state.source = Some(OtaSource::Server); assert!( - matches!(store.decide("board-1", mac, &state, true).await.unwrap(), Decision::Confirm(id) if id == job.update_id) + matches!(store.decide("board-1", mac, &state, false).await.unwrap(), Decision::Confirm(id) if id == job.update_id) ); drop(store); let store = OtaStore::open(dir.path()).unwrap(); @@ -756,7 +798,7 @@ mod tests { assert_eq!(store.job("board-1").await.unwrap().phase, Phase::Succeeded); let mut newer = efi_image(); newer.push(1); - let candidate = store.put_image(&newer, None).unwrap(); + let candidate = store.put_image(&newer, None).await.unwrap(); state.trial = true; state.pending_update_id = Some("01234567-89ab-cdef-0123-456789abcdef".into()); state.source = Some(OtaSource::Direct); @@ -778,7 +820,10 @@ mod tests { async fn active_update_can_be_cancelled_and_replaced_after_restart() { let dir = tempfile::tempdir().unwrap(); let store = OtaStore::open(dir.path()).unwrap(); - let image = store.put_image(&efi_image(), Some("first".into())).unwrap(); + let image = store + .put_image(&efi_image(), Some("first".into())) + .await + .unwrap(); let mac = "02:00:00:00:00:01".parse().unwrap(); let job = store .queue("board-1".into(), mac, &image.sha256) @@ -815,6 +860,7 @@ mod tests { replacement.push(1); let replacement = store .put_image(&replacement, Some("second".into())) + .await .unwrap(); assert!( store @@ -830,7 +876,9 @@ mod tests { let store = OtaStore::open(dir.path()).unwrap(); let image = store .put_image(&efi_image(), Some("delete-me".into())) + .await .unwrap(); + assert_eq!(store.images().await.unwrap()[0].sha256, image.sha256); let mac = "02:00:00:00:00:01".parse().unwrap(); let job = store .queue("board-1".into(), mac, &image.sha256) @@ -850,7 +898,7 @@ mod tests { )); store.cancel("board-1", &job.update_id).await.unwrap(); store.delete_image(&image.sha256).await.unwrap(); - assert!(store.images().unwrap().is_empty()); + assert!(store.images().await.unwrap().is_empty()); assert!(!image_path.exists()); assert!(!metadata_path.exists()); let retained = store.job("board-1").await.unwrap(); @@ -864,6 +912,7 @@ mod tests { store .put_image(&efi_image(), Some("delete-me".into())) + .await .unwrap(); fs::remove_file(&metadata_path).unwrap(); store.delete_image(&image.sha256).await.unwrap(); @@ -872,7 +921,7 @@ mod tests { drop(store); let store = OtaStore::open(dir.path()).unwrap(); - assert!(store.images().unwrap().is_empty()); + assert!(store.images().await.unwrap().is_empty()); let retained = store.job("board-1").await.unwrap(); assert_eq!(retained.phase, Phase::Cancelled); assert_eq!(retained.image.sha256, image.sha256); @@ -881,4 +930,36 @@ mod tests { assert!(!image_path.exists()); assert!(!metadata_path.exists()); } + + #[tokio::test] + async fn inconsistent_delete_evicts_the_cached_image_and_does_not_block_restart() { + let dir = tempfile::tempdir().unwrap(); + let store = OtaStore::open(dir.path()).unwrap(); + let image = store.put_image(&efi_image(), None).await.unwrap(); + let image_path = store.image_path(&image.sha256).unwrap(); + fs::remove_file(&image_path).unwrap(); + fs::create_dir(&image_path).unwrap(); + + assert!(matches!( + store.delete_image(&image.sha256).await, + Err(DeleteImageError::Inconsistent) + )); + assert!(store.images().await.unwrap().is_empty()); + + drop(store); + let store = OtaStore::open(dir.path()).unwrap(); + assert!(store.images().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn inconsistent_image_metadata_does_not_prevent_restart() { + let dir = tempfile::tempdir().unwrap(); + let store = OtaStore::open(dir.path()).unwrap(); + let image = store.put_image(&efi_image(), None).await.unwrap(); + fs::remove_file(store.image_path(&image.sha256).unwrap()).unwrap(); + drop(store); + + let store = OtaStore::open(dir.path()).unwrap(); + assert!(store.images().await.unwrap().is_empty()); + } } diff --git a/ostool-server/webui/src/pages/BoardEditor.test.tsx b/ostool-server/webui/src/pages/BoardEditor.test.tsx index 5e956967..126efb56 100644 --- a/ostool-server/webui/src/pages/BoardEditor.test.tsx +++ b/ostool-server/webui/src/pages/BoardEditor.test.tsx @@ -47,6 +47,23 @@ function mount(board?: BoardConfig) { , ); } +function httpBootBoard(bootArch?: string): BoardConfig { + return { + id: "http-board", + board_type: "uefi-http", + tags: [], + notes: null, + disabled: false, + serial: null, + network_identity: { mac_address: "02:00:00:00:00:31" }, + power_management: { + kind: "custom", + power_on_cmd: "true", + power_off_cmd: "true", + }, + boot: { kind: "httpboot", boot_arch: bootArch }, + }; +} it("only prefills the MAC and does not require board identity for a power command", async () => { const power = vi.spyOn(api, "powerAction").mockResolvedValue({ id: "one", @@ -153,3 +170,15 @@ it("switches to U-Boot without requiring or submitting a stale MAC", async () => }), ); }); + +it("hides loader OTA for an unsupported HTTP Boot architecture", () => { + mount(httpBootBoard("aarch64")); + + expect(screen.queryByText("axloader OTA")).not.toBeInTheDocument(); +}); + +it("shows loader OTA when the HTTP Boot architecture is omitted", () => { + mount(httpBootBoard()); + + expect(screen.getByText("axloader OTA")).toBeInTheDocument(); +}); diff --git a/ostool-server/webui/src/pages/BoardEditor.tsx b/ostool-server/webui/src/pages/BoardEditor.tsx index 42d18c89..f2febcf9 100644 --- a/ostool-server/webui/src/pages/BoardEditor.tsx +++ b/ostool-server/webui/src/pages/BoardEditor.tsx @@ -539,7 +539,11 @@ function Editor({ board }: { board?: BoardConfig }) { - {board?.network_identity && } + {board?.network_identity && + board.boot.kind === "httpboot" && + (!board.boot.boot_arch || board.boot.boot_arch === "x86_64") && ( + + )} ); }