diff --git a/Cargo.lock b/Cargo.lock index 59513f26..96c1fcf2 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", @@ -2773,6 +2773,7 @@ dependencies = [ "nix 0.30.1", "notify", "nusb", + "reqwest", "rust-embed", "schemars", "serde", 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/README.en.md b/README.en.md index 051a41f7..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 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, 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. @@ -357,7 +357,7 @@ interface = "eth0" board_ip = "192.168.1.100" ``` -`initramfs` is the **host** archive, separate from a Linux guest initrd. Direct AArch64/RISC-V QEMU boot passes it with `-initrd` and passes `cmdline` with `-append`; x86 uses `EFI/BOOT/initramfs.cpio` and `cmdline.txt` on the UEFI ESP, not the Linux x86 boot protocol for a bare ELF. U-Boot includes the archive as a FIT ramdisk and sets `bootargs` before booting; the serial command path rejects a `cmdline` containing a single quote. For board HTTP Boot, the archive and kernel belong to the same session: the server records the archive size and SHA-256, and a v3 loader verifies them before handoff. The archive limit is 256 MiB and the command-line limit is 4095 printable ASCII bytes. A v2 loader can still boot a session without either new field; the server rejects a v2 poll for a boot requiring either field. Firmware and kernels on physical boards must implement the corresponding handoff. +`BootPayloadConfig.cmdline` and `initramfs` are independent optional fields. They are top-level runtime settings for QEMU, board, and U-Boot configurations rather than build settings. `initramfs` is the **host** archive, separate from a Linux guest initrd. Direct AArch64/RISC-V QEMU boot passes it with `-initrd` and passes `cmdline` with `-append`; x86 uses `EFI/BOOT/initramfs.cpio` and `cmdline.txt` on the UEFI ESP, not the Linux x86 boot protocol for a bare ELF. U-Boot includes the archive as a FIT ramdisk and sets `bootargs` before booting; the serial command path rejects a `cmdline` containing a single quote. For board HTTP Boot, the archive and kernel belong to the same session and the server records the archive size and SHA-256. For v5 devices the server converts the compatibility entry to `__x86_64_efi_pe_entry` before pushing the job; cmdline uses EFI LoadOptions and initramfs uses the TGOS `BootPayload` table. The v2/v3/v4 poll path retains `httpboot_entry`. The archive limit is 256 MiB and the command-line limit is 4095 printable ASCII bytes. A v2 loader can still boot a session without either field; the server rejects a v2 poll for a boot requiring either field. Firmware and kernels on physical boards must implement the corresponding handoff. ### Ordered shell initialization steps diff --git a/README.md b/README.md index 9a0b66e2..4eed166b 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/`,保留原文件和原因,其余有效板卡继续加载。 @@ -333,7 +333,7 @@ interface = "eth0" board_ip = "192.168.1.100" ``` -`BootPayloadConfig` 的 `initramfs` 是宿主归档,与 Linux guest 的 initrd 分开配置。QEMU 直启仅在 AArch64/RISC-V 路径使用 `-initrd` 和 `-append`;x86 使用 UEFI ESP 中的 `EFI/BOOT/initramfs.cpio`、`cmdline.txt`,不向裸 ELF 传 Linux x86 启动协议参数。U-Boot 的 `generate_fit_image()` 将归档作为 FIT ramdisk,并在启动前设置 `bootargs`;串口命令路径暂不接受含单引号的 `cmdline`。board HTTP Boot 将归档和内核上传到同一 session,服务端记录归档大小与 SHA-256,v3 loader 下载后再次校验;归档上限为 256 MiB,cmdline 上限为 4095 字节且仅允许可打印 ASCII 和空格。服务端仍允许 v2 loader 启动两个新字段均未配置的旧会话,遇到任一新字段则在 poll 阶段明确拒绝。实体板卡的固件和内核须实现对应交接协议。 +`BootPayloadConfig` 的 `cmdline` 与 `initramfs` 相互独立且都可省略;它们是 QEMU、board 与 U-Boot 的顶层运行配置,不属于构建配置。`initramfs` 是宿主归档,与 Linux guest 的 initrd 分开配置。QEMU 直启仅在 AArch64/RISC-V 路径使用 `-initrd` 和 `-append`;x86 使用 UEFI ESP 中的 `EFI/BOOT/initramfs.cpio`、`cmdline.txt`,不向裸 ELF 传 Linux x86 启动协议参数。U-Boot 的 `generate_fit_image()` 将归档作为 FIT ramdisk,并在启动前设置 `bootargs`;串口命令路径暂不接受含单引号的 `cmdline`。board HTTP Boot 将归档和内核上传到同一 session,服务端记录归档大小与 SHA-256。v5 设备由服务端把兼容入口转换为 `__x86_64_efi_pe_entry` 后反向推送,cmdline 通过 EFI LoadOptions、initramfs 通过 TGOS `BootPayload` 交接;v2/v3/v4 poll 仍收到原 `httpboot_entry`。归档上限为 256 MiB,cmdline 上限为 4095 字节且仅允许可打印 ASCII 和空格。服务端仍允许 v2 loader 启动两个新字段均未配置的旧会话,遇到任一新字段则在 poll 阶段明确拒绝。实体板卡的固件和内核须实现对应交接协议。 ### 有序 Shell 初始化步骤 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..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 管理 @@ -670,7 +689,24 @@ 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 任务。 + +v5 axloader 通过 UDP 广播自身地址,ostool-server 随后调用设备的 HTTP 接口。设备 `POST /api/v1/boot/jobs` 创建事务返回 `201`,相同清单重试返回 `200`,冲突清单返回 `409`;服务端观察到同代次的其他启动 ID 时会先删除旧事务并只重试创建一次,删除返回 `404` 也视为旧事务已经消失。服务端构造 v5 清单时完整保留相互独立的可选 `cmdline` 与 `initramfs` 元数据,并把 Session 的兼容入口转换为 `__x86_64_efi_pe_entry`;v2/v3/v4 poll 响应继续返回原入口。`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` | +| `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;省略 `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;同一阶段的回报可安全重试。空闲检查只限制新镜像下发;若设备已运行与服务端任务匹配的待试槽,即使板卡已经进入活动 Session,服务端也会先确认该槽,使设备随后可以接受该 Session 的启动事务。v5 设备镜像投递失败会持久记录错误和次数,连续三次失败后任务进入 `failed` 并停止重发。取消已激活的任务不会远程改写设备状态,已进入待试槽的设备会在未获确认时按自身 A/B 规则回滚。非终态任务引用的镜像不能删除;删除终态任务曾引用的镜像只回收 EFI 文件和镜像元数据,任务中保存的摘要、版本和长度仍可用于审计。 ### 查询开发板类型 diff --git a/docs/axloader-network-control.md b/docs/axloader-network-control.md index ac15d68d..7b297b8a 100644 --- a/docs/axloader-network-control.md +++ b/docs/axloader-network-control.md @@ -1,140 +1,106 @@ -# axloader 网络控制与虚拟板 +# axloader 网络控制与本地验证 -本文说明 `httpboot-protocol` v3、`axloader`、`ostool-server`、`ostool` CLI 和管理页面之间的网络启动契约。服务端仍接受不使用宿主 initramfs/cmdline 的 v2 loader;该契约不兼容旧版串口 `READY/BOOT` 协议。 +## 1. 设备所有权 -## 设计边界 +### 1.1 入口与发现 -- 控制面只使用 UDP 发现和 HTTP。串口只承载目标系统的原始输入输出。 -- `BoardConfig.network_identity.mac_address` 是板卡和配置之间唯一持久绑定;探测记录只驻留内存。 -- `board_type`、板卡 ID、电源、串口和启动配置始终由管理员填写。SMBIOS 仅辅助辨认硬件,不推断 `board_type`。 -- MAC 是绑定键,不是认证凭据。当前协议用于受信实验室二层网络;HTTP 和镜像 SHA-256 不抵抗同网段主动攻击。 -- 服务重启后会重新读取板卡 TOML,但不恢复旧 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 时可独立控制装载器。 +设备没有可写 ESP 或 OTA 状态区时,状态响应中的 `ota` 可以为空;服务端记录 +告警并跳过升级决策,已有 Session 的普通启动推送仍然可用。 -## 启动流程 +广播和 HTTP 没有身份认证。MAC 仅作板卡配置绑定键;报文和镜像的 SHA-256 +仅检验一致性,本阶段限定可信隔离实验网。服务器对重复 MAC 且不同来源 IP +的在线设备停止下发任务,新的启动代次替代同地址的旧实例;设备拒绝旧代次的 +修改请求。 + +### 1.2 启动交接 + +`BootServer` 在内存维护一个 `DeviceBootJob`。内核和可选 initramfs 由调用方 +PUT 上传,各自按清单长度及 SHA-256 检验,每个文件不超过 256 MiB。cmdline +和 initramfs 相互独立且都可省略。`POST /api/v1/boot/jobs/{id}/start` 只接受 +x86_64 ELF64 的 `__x86_64_efi_pe_entry`,把 cmdline 安装为 EFI LoadOptions, +并仅在归档存在时发布 TGOS `BootPayload` 配置表;回复 `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 -``` - -`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 启动代次: - -- 新代次可以替换不再上报的旧代次; -- 被替换的旧代次若再次上报,则该 MAC 进入冲突状态; -- 冲突期间 server 返回 `duplicate_mac`,不下发启动命令; -- 10 秒没有 poll 的设备显示为离线,探测记录保留 24 小时; -- 每次 poll 都从当前板卡 TOML 重新计算 `bound_board_id`,不保存第二份绑定关系。 - -axloader 如果发现两个不同的 `server_id`,不会随机选择其中一个,而是按 1、2、4、8、10 秒封顶退避重新发现。 - -## HTTP 接口 - -| 方法与路径 | 调用者 | 含义 | -| --- | --- | --- | -| `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}` | 管理页面 | 停止并删除未绑定虚拟设备 | - -`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" + 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: 释放网络对象并交接内核 ``` -MAC 保存为小写六字节冒号格式并全局唯一。HTTP Boot 板卡缺少 MAC 时配置无效。只有板卡处于 `idle` 时才允许修改 MAC、重命名或删除;使用中和释放中返回 `409 Conflict`。重复绑定返回错误码 `mac_already_bound`。 - -管理页面通过 `/api/v1/admin/events` 接收设备快照与增量事件,取消定时刷新。创建页可以在未保存板卡、未填写 MAC 时直接按当前电源配置手动上电或下电;设备上报后实时出现在 MAC 选择列表。选择探测设备只会把 MAC 带入编辑器并展示 IP、架构、loader 版本和 SMBIOS;不会自动填写板卡 ID、`board_type`、电源、串口或启动设置。也可以手工输入 MAC。 - -## ostool CLI 行为 - -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` 释放流程并断电。 - -`.board.toml` 中的 `initramfs` 和 `cmdline` 都是可选字段。前者是宿主归档, -不是虚拟机内 Linux guest 的 initrd;后者传给宿主内核。只有目标 loader、 -固件与内核实现了对应交接时才配置这些字段。 - -## 内建 QEMU 虚拟板 - -虚拟板默认关闭。第一阶段固定为 `x86_64 + OVMF + q35`,使用 TCG,协议类型仍保留其他架构值。示例 server 配置: +ostool-server 保留原有 Session、串口 WebSocket、启动清单和板卡租约。会话 +上传文件后,`device::push_boot()` 从会话存储读取并重新核对长度与摘要, +再调用设备接口;它不把文件 URL 交给装载器。设备在同一 Session 内复位时, +服务器观察新启动代次并按原 `boot_id` 重新推送。串口只承载目标系统输出。 +若同一启动代次仍保留其他 `boot_id`,服务器在创建返回 `409` 后按状态中的旧 ID +删除该事务,再尝试创建一次;第二次仍冲突则停止本次推送,等待后续设备广播。 + +## 2. 设备协议 + +### 2.1 启动事务 + +所有修改调用携带当前 `/api/v1/status` 返回的 `X-Boot-Epoch`。 +`BootServer::create()` 只接受 x86_64 ELF64、非空合法启动 ID、预期长度与 +摘要、固定 EFI 入口,以及合法的可选命令行;相同 ID 和清单的重试保留已接收文件, +冲突清单返回 `409`。设备只保留一个启动事务,可在启动前取消。 + +| 方法与路径 | 结果 | +| --- | --- | +| `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}` | 未交接时释放事务和文件 | + +请求头限制 4 KiB;仅接受 `Content-Length`,连接空闲 30 秒后取消,短读和 +错误摘要或上传中断释放启动事务。长时间接收过程中等待路径继续驱动 UDP 广播;单个设备 +一次只处理一个 TCP 连接。需要同时上传或查询的客户端应等当前请求完成。 + +### 2.2 装载器升级 + +设备复用 `ota::OtaContext` 和现有 A/B 启动器:流式写入非活动槽,完成文件 +摘要、PE 架构与 UEFI 加载校验后才持久记录待试升级并复位。待试槽未收到 +相同升级 ID、相同来源的确认时拒绝内核启动;复位或加载失败由启动器回滚。 + +| 方法与路径 | 结果 | +| --- | --- | +| `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;只有对应待试槽可以持久提交 | + +直连上传默认生成 ID,且来源为 `direct`。服务端指派传入持久任务 ID, +以及 `X-Update-Source: server`;`OtaStore::decide()` 只在板卡空闲且非其他 +待试升级时下发。新槽广播后,服务端核对当前板卡绑定、升级 ID、运行摘要与 +来源,才调用确认接口;此确认不受板卡租约状态限制,活动 Session 可以在确认 +解锁后继续启动。设备持久提交后服务器任务变为成功。服务重启读取独立 +于 Session 的镜像库和任务。直连升级不会被服务端自动确认。 +服务端只向配置为 x86_64 UEFI HTTP 的板卡指派当前 AMD64 EFI 镜像;配置省略 +`boot_arch` 时沿用现有 CLI 语义,按 `x86_64` 处理。设备拒绝 +镜像或传输失败时,任务持久记录错误和投递次数;连续三次失败后进入 `failed`, +不再重复传输完整镜像。同一阶段的设备回报按幂等请求处理。 +管理端可删除没有被非终态任务引用的镜像;终态任务保留自身的摘要、版本和长度, +因此删除文件不破坏历史任务记录。镜像上传和删除通过同一目录门闩串行,元数据 +列表在启动时载入内存并只在增删成功后更新;这既避免 SSE 快照重复扫描目录,也 +防止并发上传、删除或指派留下只有元数据、只有 EFI 文件或悬空任务的可见状态。 + +## 3. 内建 QEMU 虚拟板 + +虚拟板默认关闭,当前固定使用 `x86_64 + OVMF + q35 + TCG`。示例服务端配置: ```toml [loader_network] @@ -156,7 +122,8 @@ memory_mib = 512 cpus = 2 ``` -本地网络环境命令需要创建 network namespace、veth、bridge、TAP 和 dnsmasq,因此应以具备 `CAP_NET_ADMIN` 的身份运行: +相对路径按配置文件目录解析。`virtual-lab` 需要创建 network namespace、veth、 +bridge、TAP 和 dnsmasq,应以具备 Linux `CAP_NET_ADMIN` 的身份运行: ```bash ostool-server --config /etc/ostool-server/config.toml virtual-lab up @@ -164,9 +131,10 @@ 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`,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: +默认客户机网段为 `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] @@ -188,15 +156,55 @@ 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`。 +`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、上传 ELF并连接串口,确认下载、摘要校验、handoff 和串口成功标志。 -4. 保持 WebSocket 和 Session,执行虚拟板 `Off → On`,确认新 `registration_id` 取得相同 `boot_id` 并再次 handoff。 +2. 填写板卡 ID、类型、QEMU 电源和串口,并选择探测到的 MAC。 +3. 使用 `ostool` 创建 Session、上传内核及可选 initramfs/cmdline,确认设备 HTTP 交接和内核串口标志。 +4. 保持 WebSocket 和 Session,执行虚拟板 `Off -> On`,确认新启动代次重新取得相同启动事务并再次交接。 5. 关闭 WebSocket,确认 Session 经 `releasing` 回到 `idle` 且 QEMU 退出。 6. 新建 Session 再运行一次,全程不修改绑定。 -实体板按相同顺序验收首次绑定、活动 Session 内重启、释放和新 Session;差异只在电源与串口后端。 +## 4. 兼容与本地联调 + +### 4.1 兼容边界 + +ostool-server 继续接受旧装载器 v2/v3/v4 的 UDP Offer、 +`POST /api/v1/loaders/poll`、状态上报和下载 URL;`httpboot-protocol` 中的 +`PROTOCOL_VERSION` 继续标识这条旧路径,`DEVICE_PROTOCOL_VERSION` 标识 v5。 +服务端只在 `device::push_boot()` 构造 v5 设备清单时把 Session 的 +`httpboot_entry` 转换为 `__x86_64_efi_pe_entry`;v2/v3/v4 poll 继续收到原入口 +和原字段,旧装载器不需要解析新入口。 +v5 装载器只广播,不调用任何服务端 HTTP 接口。TGOS 当前依赖已发布 +`httpboot-protocol 0.3.0`;其 v5 设备请求类型在本地定义,两仓用实际 HTTP +契约测试核对,代码交付不依赖另一仓的绝对路径或发布新 crate。 + +### 4.2 隔离测试 + +`ostool-server/scripts/test-axloader-local.py` 启动本地 ostool-server 与 OVMF/QEMU, +在临时目录创建板卡 TOML、服务端配置、OVMF VARS 和真实 FAT 磁盘。管理入口 +使用独立 loopback 端口,关闭测速与系统 TFTP 接管;QEMU `hostfwd` 指向真实 +客户机 TCP4 监听。测试夹具仅转发 QEMU 广播帧并将公告端口换成本地 +`hostfwd` 端口,服务端必须直接 HTTP 请求客户机完成启动与 OTA。 +Session 启动使用 TGOS 已构建的真实 `arceos-helloworld`,同时上传可选 initramfs +并发送 cmdline;成功条件来自内核输出的 `HOST_CMDLINE`、 +`HOST_INITRAMFS_PASSED` 和 `Hello, world!`,不以装载器准备交接日志代替。 +默认内核来自 TGOS 的 axloader QEMU 测试产物,也可用 `--kernel` 和 +`--initramfs` 显式指定。 + +```bash +cargo build -p ostool-server +python3 ostool-server/scripts/test-axloader-local.py \ + --tgos /path/to/tgoskits-dev \ + --server-bin target/debug/ostool-server +``` + +TGOS 单仓用 `cargo xtask axloader test qemu --target x86_64-unknown-uefi` 验证 +同一真实 FAT 映像上的直连启动、镜像升级、坏摘要、短请求、待试复位和持久确认。 +本地测试不调用安装、更新或 systemd 脚本,不连接 runner,也不刷写实体板卡。 +`hostfwd` 证明本地真实 HTTP 反向调用,不证明实体网络广播与反向路由;后者 +属于日后上线前的单独验收。 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..ab55482c 100644 --- a/httpboot-protocol/src/lib.rs +++ b/httpboot-protocol/src/lib.rs @@ -8,7 +8,10 @@ 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; +/// 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; pub const MAX_HTTP_BOOT_INITRAMFS_BYTES: usize = 256 * 1024 * 1024; @@ -166,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)] @@ -189,6 +254,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 +302,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/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..12cbf9fe --- /dev/null +++ b/ostool-server/scripts/test-axloader-local.py @@ -0,0 +1,318 @@ +#!/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 relay_beacons(capture, guest_port, stopped): + capture.settimeout(0.5) + outbound = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + while not stopped.is_set(): + try: + stream, _ = capture.accept() + except socket.timeout: + continue + except OSError: + if stopped.is_set(): + break + raise + with stream: + stream.settimeout(0.5) + frames = bytearray() + while not stopped.is_set(): + try: + chunk = stream.recv(65536) + except socket.timeout: + continue + if not chunk: + break + frames.extend(chunk) + while len(frames) >= 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("--kernel", type=Path) + parser.add_argument("--initramfs", type=Path) + 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) + kernel_path = ( + args.kernel + or tgos / "target/x86_64-unknown-linux-musl/release/arceos-helloworld" + ).resolve() + kernel = kernel_path.read_bytes() + if args.initramfs: + initramfs_path = args.initramfs.resolve() + else: + initramfs_path = root / "host-initramfs.cpio" + run( + str(tgos / "target/debug/tg-xtask"), + "image", + "pack-initramfs", + str(tgos / "test-suit/host-initramfs"), + str(initramfs_path), + cwd=tgos, + ) + initramfs = initramfs_path.read_bytes() + 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"] + request(base + "/api/v1/sessions/{}/http-boot/files".format(session_id), "PUT", initramfs, + {"X-File-Path": "initramfs.cpio"}) + request(base + "/api/v1/sessions/{}/http-boot/kernel".format(session_id), "PUT", kernel, + {"X-HttpBoot-Arch": "x86_64", "X-HttpBoot-Image-Format": "elf64", + "X-HttpBoot-Remote-Name": "kernel.elf", + "X-HttpBoot-Entry-Symbol": "httpboot_entry", + "X-HttpBoot-Initramfs-Path": "initramfs.cpio", + "X-HttpBoot-Cmdline": "axloader.cmdline=ostool-local"}) + wait_for("server pushed optional payloads into a real ArceOS kernel", lambda: ( + b'HOST_CMDLINE: axloader.cmdline=ostool-local' in (root / "qemu-1.log").read_bytes() + and b'HOST_INITRAMFS_PASSED' in (root / "qemu-1.log").read_bytes() + and b'Hello, world!' 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(), + "kernel_sha256": hashlib.sha256(kernel).hexdigest(), + "initramfs_sha256": hashlib.sha256(initramfs).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/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..8dc47f6b --- /dev/null +++ b/ostool-server/src/api/ota.rs @@ -0,0 +1,255 @@ +//! 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}, + config::{BootConfig, UefiBootArch}, + ota::{DeleteImageError, 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/loader-images/{sha256}", + axum::routing::delete(delete_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().await?)) +} + +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 { + 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 + .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) + .await + .map_err(|error| ApiError::bad_request(format!("{error:#}")))?; + state.admin_events.invalidate(&["ota"]); + 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, +} + +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"))?; + if !matches!( + &board.boot, + BootConfig::UefiHttp(profile) + 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", + )); + } + 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 && !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()) { + 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| { + 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")); + } + 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..9b0b788b 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 @@ -2713,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().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), + "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; @@ -2725,8 +2833,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; @@ -2799,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; @@ -3548,6 +3667,7 @@ mod tests { arch: BootArch::X86_64, loader_version: "test-loader".into(), hardware: LoaderHardwareInfo::default(), + ota: None, }) .await .unwrap(); @@ -4715,6 +4835,7 @@ mod tests { .uri(format!("/api/v1/sessions/{session_id}/http-boot/kernel")) .header("X-HttpBoot-Arch", "x86_64") .header("X-HttpBoot-Image-Format", "elf64") + .header("X-HttpBoot-Entry-Symbol", "httpboot_entry") .header("X-HttpBoot-Initramfs-Path", "initramfs.cpio") .header("X-HttpBoot-Cmdline", "console=ttyS0 -- test") .body(Body::from("kernel-elf")) @@ -4724,7 +4845,11 @@ mod tests { .unwrap(); assert_eq!(published.status(), StatusCode::CREATED); - for version in [PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION] { + for version in [ + PROTOCOL_VERSION, + PREVIOUS_PROTOCOL_VERSION, + LEGACY_PROTOCOL_VERSION, + ] { let probe = LoaderDiscoveryProbe { protocol_version: version, mac_address: mac, @@ -4747,6 +4872,15 @@ mod tests { arch: BootArch::X86_64, loader_version: "test-loader".into(), hardware: LoaderHardwareInfo::default(), + ota: (version == PROTOCOL_VERSION).then(|| 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() @@ -4768,6 +4902,7 @@ mod tests { ); } else { let LoaderPollResponse::Boot { + entry_symbol, initramfs: Some(file), cmdline, .. @@ -4782,6 +4917,7 @@ mod tests { assert_eq!(file.size, archive.len() as u64); assert_eq!(file.sha256, hex_sha256(archive)); assert_eq!(cmdline.as_deref(), Some("console=ttyS0 -- test")); + assert_eq!(entry_symbol.as_deref(), Some("httpboot_entry")); } } } @@ -4820,7 +4956,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 +4968,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 +4981,7 @@ mod tests { version: None, serial: None, }, + ota: None, }; let first_poll = make_poll(first.registration_id.clone()); @@ -4867,7 +5004,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,67 +5849,333 @@ mod tests { assert_eq!(value["code"], "bad_request"); assert_eq!(value["message"], "board_type must not be empty"); } -} -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 { - &[] + #[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() }; - state.admin_events.invalidate(topics); + assert_eq!( + app.clone().oneshot(delete()).await.unwrap().status(), + StatusCode::NO_CONTENT + ); + assert_eq!( + app.oneshot(delete()).await.unwrap().status(), + StatusCode::NOT_FOUND + ); } - 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), - "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())) + #[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 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, + StatusCode::CREATED + ); + 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.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 mut arm_board = sample_httpboot_board("ota-arm-board"); + arm_board.board_type = "aarch64-uefi-http".into(); + arm_board.serial = None; + arm_board.boot = BootConfig::UefiHttp(UefiHttpProfile { + boot_arch: Some(UefiBootArch::Aarch64), + }); + arm_board.network_identity = Some(crate::config::BoardNetworkIdentity { + mac_address: "02:00:00:00:00:02".parse().unwrap(), + }); + assert_eq!( + create_board(&app, serde_json::to_value(&arm_board).unwrap()).await, + StatusCode::CREATED + ); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!( + "/api/v1/admin/boards/{}/loader-updates", + arm_board.id + )) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + json!({"image_sha256": uploaded.sha256}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); + let error: crate::api::models::ErrorResponse = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!( + error.message, + "loader OTA requires an x86_64 UEFI HTTP board" + ); + 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(); + 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 + ); + 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.clone(); + 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.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 new file mode 100644 index 00000000..e1eae87b --- /dev/null +++ b/ostool-server/src/device.rs @@ -0,0 +1,1003 @@ +//! 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, Job, Phase}, + session::SessionBootCommand, + state::{AppState, BoardLeaseState}, +}; + +const V5_EFI_ENTRY_SYMBOL: &str = "__x86_64_efi_pe_entry"; + +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 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()?; + 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"]); + if let Err(error) = + push_ota_image(&state, &client, &endpoint, &observed, &job).await + { + state + .ota + .record_delivery_failure( + &board_id, + announcement.mac_address, + &job.update_id, + format!("{error:#}"), + ) + .await?; + state.admin_events.invalidate(&["ota"]); + return Err(error); + } + 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 { + 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(()); + } + 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, + &session_id, + &command, + &session, + ) + .await +} + +async fn push_ota_image( + state: &AppState, + client: &Client, + endpoint: &str, + observed: &LoaderDeviceStatus, + job: &Job, +) -> anyhow::Result<()> { + 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 +} + +async fn push_boot( + state: &AppState, + client: &Client, + endpoint: &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 = v5_boot_manifest(command); + + let base = format!("{endpoint}/api/v1/boot/jobs"); + 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_in(delete_response, &[204, 404]).await?; + create().await? + } else { + response + }; + 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(()) +} + +fn v5_boot_manifest(command: &SessionBootCommand) -> DeviceBootJob { + 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: Some(V5_EFI_ENTRY_SYMBOL.into()), + } +} + +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<()> { + require_status_in(response, &[expected]).await +} + +async fn require_status_in(response: Response, expected: &[u16]) -> anyhow::Result<()> { + let status = response.status(); + ensure!( + expected.contains(&status.as_u16()), + "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 +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::{ + Json, Router, + body::Bytes, + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::{delete, get, post, put}, + }; + use httpboot_protocol::{ + BootArch, BootFile, DEVICE_PROTOCOL_VERSION, DeviceBootJob, DeviceBootStatus, ImageFormat, + LoaderAnnouncement, LoaderDeviceStatus, LoaderHardwareInfo, LoaderOtaState, OtaOutcome, + OtaSource, + }; + use serde::Deserialize; + use tokio::{net::TcpListener, sync::Mutex}; + + use super::{V5_EFI_ENTRY_SYMBOL, hex_sha256, reconcile, v5_boot_manifest}; + 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, + delete_attempts: usize, + upload_attempts: usize, + start_attempts: usize, + delete_returns_not_found: bool, + reject_create_without_manifest: bool, + reject_upload: bool, + kernel: Vec, + } + + type FakeBootState = Arc>; + + #[test] + fn v5_manifest_preserves_optional_boot_payloads_and_uses_efi_entry() { + for (cmdline, initramfs) in [ + (None, None), + (Some("console=ttyS0".to_string()), None), + ( + None, + Some(BootFile { + path: "/boot/sessions/session/initramfs.cpio".into(), + size: 123, + sha256: "22".repeat(32), + }), + ), + ( + Some("console=ttyS0".to_string()), + Some(BootFile { + path: "/boot/sessions/session/initramfs.cpio".into(), + size: 123, + sha256: "22".repeat(32), + }), + ), + ] { + let command = SessionBootCommand { + boot_id: "boot-1".into(), + kernel_path: "/boot/sessions/session/kernel.elf".into(), + kernel_size: 456, + kernel_sha256: "11".repeat(32), + arch: BootArch::X86_64, + image_format: ImageFormat::Elf64, + entry_symbol: Some("httpboot_entry".into()), + initramfs: initramfs.clone(), + cmdline: cmdline.clone(), + }; + + let manifest = v5_boot_manifest(&command); + + assert_eq!(manifest.cmdline, cmdline); + assert_eq!(manifest.entry_symbol.as_deref(), Some(V5_EFI_ENTRY_SYMBOL)); + assert_eq!( + manifest.initramfs, + initramfs.map(|file| httpboot_protocol::DeviceBootImage { + size: file.size, + sha256: file.sha256, + }) + ); + } + } + + 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 => { + 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() + } + Some(current) if current == &manifest => StatusCode::OK.into_response(), + Some(_) => (StatusCode::CONFLICT, "boot manifest changed").into_response(), + } + } + + 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.delete_returns_not_found { + device.manifest = None; + device.status.boot = None; + return StatusCode::NOT_FOUND.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, + body: Bytes, + ) -> Response { + 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); + 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())) + .await + .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 failed = state.ota.job("board-1").await.unwrap(); + assert_eq!(failed.delivery_attempts, 1); + assert_eq!( + failed.error.as_deref(), + Some("device HTTP 409 Conflict: stale boot epoch") + ); + + { + 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_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; + 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 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, + 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: Some(DeviceBootStatus { + boot_id: stale_manifest.boot_id.clone(), + phase: "accepted".into(), + kernel_received: false, + initramfs_received: true, + last_error: None, + }), + ota: None, + }, + manifest: Some(stale_manifest), + create_attempts: 0, + delete_attempts: 0, + upload_attempts: 0, + start_attempts: 0, + delete_returns_not_found: false, + 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()); + 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.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; + { + let mut fake = fake.lock().await; + fake.delete_returns_not_found = true; + fake.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, 5); + assert_eq!(fake.delete_attempts, 2); + assert_eq!(fake.upload_attempts, 2); + assert_eq!(fake.start_attempts, 1); + assert!(fake.manifest.is_none()); + server.abort(); + } +} diff --git a/ostool-server/src/lib.rs b/ostool-server/src/lib.rs index 2d425069..fd24be43 100644 --- a/ostool-server/src/lib.rs +++ b/ostool-server/src/lib.rs @@ -7,10 +7,12 @@ 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; 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..e2d9b7c6 100644 --- a/ostool-server/src/loader.rs +++ b/ostool-server/src/loader.rs @@ -1,10 +1,14 @@ -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, + 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; @@ -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(), @@ -157,7 +220,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 +253,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 +341,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); } @@ -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, @@ -476,6 +619,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..4e5b1d63 --- /dev/null +++ b/ostool-server/src/ota.rs @@ -0,0 +1,965 @@ +//! 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; +pub(crate) const MAX_DELIVERY_ATTEMPTS: u8 = 3; + +#[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 { + pub(crate) 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, + #[serde(default)] + pub delivery_attempts: u8, +} + +#[derive(Clone, Debug)] +pub enum Decision { + Idle, + Update(Job), + Confirm(String), + 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_catalog: Arc>>, + 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()), + }; + let images = read_images(&root)? + .into_iter() + .map(|image| (image.sha256.clone(), image)) + .collect(); + Ok(Self { + root, + image_catalog: Arc::new(Mutex::new(images)), + 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 async fn images(&self) -> anyhow::Result> { + Ok(self.image_catalog.lock().await.values().cloned().collect()) + } + + 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" + ); + 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) + } + + pub async fn delete_image(&self, digest: &str) -> Result<(), DeleteImageError> { + if !valid_digest(digest) { + return Err(DeleteImageError::InvalidDigest); + } + let mut images = self.image_catalog.lock().await; + { + let jobs = self.jobs.lock().await; + if jobs + .values() + .any(|job| job.image.sha256 == digest && !job.phase.is_terminal()) + { + return Err(DeleteImageError::InUse); + } + } + 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()); + } + result + } + + 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 { + // 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!( + jobs.get(&board_id) + .is_none_or(|job| job.phase.is_terminal()), + "board already has an OTA job" + ); + 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 job = Job { + board_id: board_id.clone(), + mac_address, + update_id: uuid::Uuid::new_v4().to_string(), + image, + phase: Phase::Queued, + error: None, + delivery_attempts: 0, + }; + 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 observations = self.observations.lock().await; + let mut jobs = self.jobs.lock().await; + 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.is_terminal(), + "OTA job already finished or superseded" + ); + job.phase = Phase::Cancelled; + job.error = None; + let result = job.clone(); + + 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) + } + + 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(), + }, + ); + self.save_observations(&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 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" + ); + if job.phase == phase { + if job.error != error { + job.error = error; + self.save(&updated)?; + *jobs = updated; + } + return Ok(()); + } + ensure!(!job.phase.is_terminal(), "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(()) + } + + pub async fn record_delivery_failure( + &self, + board_id: &str, + mac_address: httpboot_protocol::MacAddress, + update_id: &str, + error: String, + ) -> anyhow::Result { + 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 delivery failure" + ); + ensure!( + matches!(job.phase, Phase::Queued | Phase::Downloading), + "OTA job is not accepting an image" + ); + job.delivery_attempts = job.delivery_attempts.saturating_add(1); + job.error = Some(error); + if job.delivery_attempts >= MAX_DELIVERY_ATTEMPTS { + job.phase = Phase::Failed; + } else { + job.phase = Phase::Downloading; + } + let result = job.clone(); + self.save(&updated)?; + *jobs = updated; + Ok(result) + } + + 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 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 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 { + 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 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())) + .await + .unwrap(); + let mac = "02:00:00:00:00:01".parse().unwrap(); + let job = store + .queue("board-1".into(), mac, &image.sha256) + .await + .unwrap(); + + for phase in [ + Phase::Downloading, + Phase::Downloading, + Phase::Staged, + Phase::Staged, + Phase::Failed, + Phase::Failed, + ] { + store + .report( + "board-1", + mac, + &job.update_id, + phase, + (phase == Phase::Failed).then(|| "device rejected image".into()), + None, + ) + .await + .unwrap(); + } + + let mut replacement = efi_image(); + replacement.push(1); + let image = store + .put_image(&replacement, Some("second".into())) + .await + .unwrap(); + let job = store + .queue("board-1".into(), mac, &image.sha256) + .await + .unwrap(); + let trial = LoaderOtaState { + active_sha256: "11".repeat(32), + running_sha256: image.sha256.clone(), + pending_update_id: Some(job.update_id.clone()), + trial: true, + source: Some(OtaSource::Server), + last_update_id: None, + last_outcome: None, + }; + assert!(matches!( + store.decide("board-1", mac, &trial, true).await.unwrap(), + Decision::Confirm(id) if id == job.update_id + )); + for _ in 0..2 { + store + .report( + "board-1", + mac, + &job.update_id, + Phase::Succeeded, + None, + Some(&image.sha256), + ) + .await + .unwrap(); + } + assert_eq!(store.job("board-1").await.unwrap().phase, Phase::Succeeded); + } + + #[tokio::test] + 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).await.unwrap(); + let mac = "02:00:00:00:00:01".parse().unwrap(); + let job = store + .queue("board-1".into(), mac, &image.sha256) + .await + .unwrap(); + + let first = store + .record_delivery_failure( + "board-1", + mac, + &job.update_id, + "device HTTP 409 Conflict".into(), + ) + .await + .unwrap(); + assert_eq!(first.phase, Phase::Downloading); + assert_eq!(first.delivery_attempts, 1); + drop(store); + + let store = OtaStore::open(dir.path()).unwrap(); + for attempt in 2..=MAX_DELIVERY_ATTEMPTS { + let updated = store + .record_delivery_failure( + "board-1", + mac, + &job.update_id, + format!("delivery attempt {attempt} failed"), + ) + .await + .unwrap(); + assert_eq!(updated.delivery_attempts, attempt); + } + let failed = store.job("board-1").await.unwrap(); + assert_eq!(failed.phase, Phase::Failed); + assert_eq!(failed.error.as_deref(), Some("delivery attempt 3 failed")); + let idle = 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, &idle, true).await.unwrap(), + Decision::Idle + )); + } + + #[tokio::test] + 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())) + .await + .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, false).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).await.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() + ); + } + + #[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())) + .await + .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())) + .await + .unwrap(); + assert!( + store + .queue("board-1".into(), mac, &replacement.sha256) + .await + .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())) + .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) + .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().await.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())) + .await + .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().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); + assert_eq!(retained.image.size, image.size); + assert_eq!(retained.image.version, image.version); + 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/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.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 a4921a0b..ab7aa0e1 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,31 @@ 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, + }); + }, + 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`, + { 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.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 4bfcd1ad..f2febcf9 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,11 @@ function Editor({ board }: { board?: BoardConfig }) { + {board?.network_identity && + board.boot.kind === "httpboot" && + (!board.boot.boot_arch || board.boot.boot_arch === "x86_64") && ( + + )} ); } 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..bbb751f0 --- /dev/null +++ b/ostool-server/webui/src/pages/BoardOta.test.tsx @@ -0,0 +1,89 @@ +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, + delivery_attempts: 0, + }; +} + +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("")); +}); + +it("allows selecting the same image after a successful upload", async () => { + applyOta(job("succeeded")); + const upload = vi.spyOn(api, "uploadLoaderImage").mockResolvedValue(image); + render(); + const user = userEvent.setup(); + const input = screen.getByLabelText("EFI 镜像(最多 32 MiB)"); + const button = screen.getByRole("button", { name: "上传镜像" }); + const file = new File(["efi"], "loader.efi", { + type: "application/octet-stream", + }); + + await user.upload(input, file); + await user.click(button); + await waitFor(() => expect(upload).toHaveBeenCalledTimes(1)); + expect(input).toHaveValue(""); + + await user.upload(input, file); + await user.click(button); + await waitFor(() => expect(upload).toHaveBeenCalledTimes(2)); +}); diff --git a/ostool-server/webui/src/pages/BoardOta.tsx b/ostool-server/webui/src/pages/BoardOta.tsx new file mode 100644 index 00000000..58594ad9 --- /dev/null +++ b/ostool-server/webui/src/pages/BoardOta.tsx @@ -0,0 +1,72 @@ +import { useRef, 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"; +import { sanitizeImageVersion } from "@/utils/ota"; + +const terminalPhases = ["succeeded", "rolled_back", "failed", "cancelled"]; + +export function BoardOta({ boardId }: { boardId: string }) { + const ota = useResource("ota"); + const fileInput = useRef(null); + 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 && !terminalPhases.includes(job.phase); + const selectedInUse = ota?.jobs.some( + (entry) => + entry.image.sha256 === selected && !terminalPhases.includes(entry.phase), + ); + return ( +
+ {job && ( +
+

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

+ {job.error && {job.error}} + {pending && ( + api.cancelLoaderUpdate(boardId, job.update_id)} /> + )} +
+ )} + + + ({ + value: image.sha256, + label: `${image.version || image.sha256.slice(0, 12)} · ${(image.size / 1024 / 1024).toFixed(2)} MiB`, + })), + ]} /> + { + await api.deleteLoaderImage(selected); + setSelected(""); + }} + /> + + {(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..91446e71 100644 --- a/ostool-server/webui/src/types/api.ts +++ b/ostool-server/webui/src/types/api.ts @@ -180,6 +180,27 @@ 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; + delivery_attempts: number; +} + +export interface LoaderOtaResources { + jobs: LoaderUpdateJob[]; + images: LoaderImage[]; +} + export interface VirtualDeviceSummary { id: string; mac_address: 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; +} diff --git a/ostool/src/boot/payload.rs b/ostool/src/boot/payload.rs index affdb146..c73daa9f 100644 --- a/ostool/src/boot/payload.rs +++ b/ostool/src/boot/payload.rs @@ -13,8 +13,10 @@ use crate::project::variables::{self, VariableScope}; #[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] pub struct BootPayloadConfig { /// Path to a host initramfs archive; guest initrds are configured separately. + #[serde(default, skip_serializing_if = "Option::is_none")] pub initramfs: Option, /// Boot arguments passed to the host kernel. + #[serde(default, skip_serializing_if = "Option::is_none")] pub cmdline: Option, } @@ -58,6 +60,44 @@ impl BootPayloadConfig { mod tests { use super::BootPayloadConfig; + #[test] + fn serializes_all_optional_payload_combinations_without_null_fields() { + for (config, expected) in [ + (BootPayloadConfig::default(), serde_json::json!({})), + ( + BootPayloadConfig { + cmdline: Some("console=ttyS0".into()), + initramfs: None, + }, + serde_json::json!({"cmdline": "console=ttyS0"}), + ), + ( + BootPayloadConfig { + cmdline: None, + initramfs: Some("host.cpio".into()), + }, + serde_json::json!({"initramfs": "host.cpio"}), + ), + ( + BootPayloadConfig { + cmdline: Some("console=ttyS0".into()), + initramfs: Some("host.cpio".into()), + }, + serde_json::json!({ + "cmdline": "console=ttyS0", + "initramfs": "host.cpio", + }), + ), + ] { + let encoded = serde_json::to_value(&config).unwrap(); + assert_eq!(encoded, expected); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + config + ); + } + } + #[test] fn rejects_control_bytes_and_oversize() { for value in [ @@ -90,5 +130,13 @@ mod tests { .validate() .is_ok() ); + assert!( + BootPayloadConfig { + cmdline: Some(String::new()), + ..Default::default() + } + .validate() + .is_ok() + ); } }