diff --git a/.gitignore b/.gitignore index df77660d..178dc620 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,6 @@ target/ /.ostool-server /ostool-server/web/dist /ostool-server/webui/node_modules +/ostool-server/webui/playwright-report +/ostool-server/webui/test-results .build.toml diff --git a/Cargo.lock b/Cargo.lock index 3e67e623..15dcdb3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1627,7 +1627,7 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpboot-protocol" -version = "0.1.1" +version = "0.2.0" dependencies = [ "serde", "serde_json", @@ -2663,6 +2663,7 @@ dependencies = [ "chrono", "clap", "env_logger", + "fs4", "futures-util", "httpboot-protocol", "log", diff --git a/Cargo.toml b/Cargo.toml index 97278692..c765a846 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.1", path = "./httpboot-protocol" } +httpboot-protocol = { version = "0.2", path = "./httpboot-protocol" } # Error handling thiserror = "2" diff --git a/README.en.md b/README.en.md index e03ea5ba..6c4914c7 100644 --- a/README.en.md +++ b/README.en.md @@ -15,6 +15,8 @@ ## 📖 Project Overview +See [docs/axloader-network-control.md](docs/axloader-network-control.md) for the axloader 0.2 network control, 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). + **ostool** is a Rust toolset designed specifically for operating system development, aiming to provide OS developers with convenient build, configuration, and startup environments. It's particularly suitable for embedded system development, supporting system testing and debugging through Qemu virtual machines and U-Boot bootloader. ### ✨ Core Features diff --git a/README.md b/README.md index ef4cb6fd..f881e735 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ ## 📖 项目简介 +axloader 0.2 的网络控制、持久 MAC 绑定、Web 管理和内建 QEMU 虚拟板设计见 [docs/axloader-network-control.md](docs/axloader-network-control.md),完整接口契约见 [docs/api.md](docs/api.md)。 + **ostool** 是一个专为操作系统开发而设计的 Rust 工具集,旨在为 OS 开发者提供便捷的构建、配置和启动环境。它特别适合嵌入式系统开发,支持通过 Qemu 虚拟机和 U-Boot 引导程序进行系统测试和调试。 ### ✨ 核心特性 diff --git a/docs/api.md b/docs/api.md index 19f6fd05..921b3caa 100644 --- a/docs/api.md +++ b/docs/api.md @@ -179,6 +179,8 @@ token= | 开发板电源状态 | `GET /api/v1/admin/boards/{board_id}/power-status` | | 开发板租约状态 | `GET /api/v1/admin/boards/{board_id}/runtime-status` | | 串口与网卡发现 | `GET /api/v1/admin/serial-ports`;`GET /api/v1/admin/network-interfaces` | +| axloader 设备发现 | `GET /api/v1/admin/loader-devices` | +| QEMU 虚拟设备 | `GET /api/v1/admin/virtual-devices`;`POST /api/v1/admin/virtual-devices`;`DELETE /api/v1/admin/virtual-devices/{device_id}` | | DTB 列表与创建 | `GET /api/v1/admin/dtbs`;`POST /api/v1/admin/dtbs` | | 单个 DTB | `GET /api/v1/admin/dtbs/{dtb_name}`;`PUT /api/v1/admin/dtbs/{dtb_name}`;`DELETE /api/v1/admin/dtbs/{dtb_name}` | | 活动会话 | `GET /api/v1/admin/sessions`;`DELETE /api/v1/admin/sessions/{session_id}` | @@ -288,7 +290,7 @@ Content-Type: application/json - 创建时 `id` 为 `null` 或空字符串,服务端自动选择首个可用的 `{board_type}-{number}`;指定的 ID 已存在时返回 `409 Conflict`。 - 更新时 `id` 为 `null` 保持路径中的 `board_id`,指定不同 ID 表示重命名。只有租约状态为 `idle` 的开发板可以更新或删除,否则返回 `409 Conflict`。 - `board_type`、串口 key、Custom 电源命令不能为空;配置串口时 `baud_rate` 必须大于 0。请求中的 `resolved_device_path` 和 `resolved_usb_path` 会被清除,由服务端重新发现。 -- `serial.key.kind` 可为 `serial_number` 或 `usb_path`。 +- `serial.key.kind` 可为 `serial_number`、`usb_path` 或 `qemu`。`qemu` 的 value 是虚拟设备 ID。 - `power_management.kind` 可为上例的 `custom`,或中盛继电器配置: ```json @@ -298,7 +300,10 @@ Content-Type: application/json } ``` + 启用内建虚拟板时也可使用 `{"kind":"qemu","virtual_device_id":"..."}`。此时串口必须为同一个虚拟设备 ID 的 `qemu` key,并配置相同虚拟设备的 MAC。 + - `boot.kind` 可为上例的 `uboot`、`{"kind":"pxe","notes":null}`,或 `{"kind":"httpboot","boot_arch":"aarch64"}`。`boot_arch` 可为 `x86_64`、`aarch64`、`loongarch64`、`riscv64` 或 `other`。 +- `httpboot` 板卡必须提供 `network_identity: {"mac_address":"02:00:00:00:00:01"}`。MAC 会规范化为小写六字节冒号格式并在全部板卡配置中保持唯一;重复绑定返回 `409` 和错误码 `mac_already_bound`。`board_type` 始终由管理员填写,不根据 SMBIOS 或架构推断。 - U-Boot `network_mode` 可为 `dhcp` 或 `static_ip`。未启用 TFTP 或使用 DHCP 时服务端清除静态网络字段;使用 `static_ip` 时 `board_ip` 必填,所有已提供的网络字段必须是 IPv4 地址。`dtb_name` 必须符合单层 DTB 文件名格式,但创建或更新开发板时不会检查对应文件是否已经上传。 创建成功返回 `201 Created` 和规范化后的 `BoardConfig`;更新成功返回 `200 OK`。删除请求没有请求体,成功返回 `204 No Content`: @@ -382,6 +387,38 @@ GET /api/v1/admin/network-interfaces 枚举失败时返回 `503 Service Unavailable`。 +### axloader 发现与虚拟设备 + +```http +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 进程: + +```http +GET /api/v1/admin/virtual-devices +POST /api/v1/admin/virtual-devices +Content-Type: application/json + +{"mac_address":"02:00:00:00:00:09"} + +DELETE /api/v1/admin/virtual-devices/{device_id} +``` + +GET 返回 `{ "enabled": false, "devices": [] }` 或当前设备列表。POST 的 MAC 可省略,由服务生成本地管理、单播 MAC;成功返回 `201 Created`。虚拟设备必须经真实 UDP/HTTP 发现后才能绑定,管理接口不会注入探测记录。已被板卡配置引用的设备不能删除。 + +虚拟环境由服务端子命令幂等管理: + +```bash +ostool-server --config .ostool-server.toml virtual-lab up +ostool-server --config .ostool-server.toml virtual-lab status +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`。 + ### DTB 管理 ```http @@ -559,6 +596,8 @@ Content-Type: application/json 本节定义两种后端共用的开发板服务契约:本地局域网模式由 `ostool-server` 直接提供,认证模式由独立认证后端提供受认证的对应接口。这里覆盖 `ostool-server` 的全部公开、非管理 REST 接口。`ostool` 当前命令会使用会话文件上传,但不会直接调用会话详情、会话文件列表/查询/删除、显式电源控制和普通 HTTP Boot 文件上传;后者仍属于公开 board 服务契约,其中显式电源控制和普通 HTTP Boot 文件上传也已有 `BoardServerClient` 方法。 +axloader 0.2 另使用 `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` 定位,旧代次迟到上报不能覆盖新代次。Session 释放时删除启动清单和 loader 状态。 + ### 查询开发板类型 ```http @@ -723,7 +762,7 @@ GET /api/v1/sessions/{session_id}/boot-profile } ``` -`boot.kind` 可为 `uboot`、`pxe` 或 `httpboot`(客户端也接受别名 `uefi_http`)。`pxe` 的对象仅含可选 `notes`;`httpboot` 的对象含可选 `boot_arch`(`x86_64`、`aarch64`、`loongarch64`、`riscv64` 或 `other`)。客户端还兼容认证后端返回可选 `mac`,但当前 `ostool-server` 不序列化该字段。顶层 `server_ip`、`netmask`、`interface`、`http_base_url` 均可为 `null`。`server_ip` 和 `http_base_url` 使用板端可访问的网络地址,不一定等于管理网地址。 +`boot.kind` 可为 `uboot`、`pxe` 或 `httpboot`(客户端也接受别名 `uefi_http`)。`pxe` 的对象仅含可选 `notes`;`httpboot` 的对象含可选 `boot_arch`(`x86_64`、`aarch64`、`loongarch64`、`riscv64` 或 `other`)。HTTP Boot 的 MAC 位于顶层 `network_identity.mac_address`,不属于 boot profile。顶层 `server_ip`、`netmask`、`interface`、`http_base_url` 均可为 `null`。`server_ip` 和 `http_base_url` 使用板端可访问的网络地址,不一定等于管理网地址。 ### 获取串口状态 diff --git a/docs/axloader-network-control.md b/docs/axloader-network-control.md new file mode 100644 index 00000000..7da8dce0 --- /dev/null +++ b/docs/axloader-network-control.md @@ -0,0 +1,186 @@ +# axloader 网络控制与虚拟板 + +本文说明 `httpboot-protocol 0.2`、`axloader`、`ostool-server`、`ostool` CLI 和管理页面之间的网络启动契约。该契约不兼容旧版串口 `READY/BOOT` 协议。 + +## 设计边界 + +- 控制面只使用 UDP 发现和 HTTP。串口只承载目标系统的原始输入输出。 +- `BoardConfig.network_identity.mac_address` 是板卡和配置之间唯一持久绑定;探测记录只驻留内存。 +- `board_type`、板卡 ID、电源、串口和启动配置始终由管理员填写。SMBIOS 仅辅助辨认硬件,不推断 `board_type`。 +- MAC 是绑定键,不是认证凭据。当前协议用于受信实验室二层网络;HTTP 和镜像 SHA-256 不抵抗同网段主动攻击。 +- 服务重启后会重新读取板卡 TOML,但不恢复旧 Session、启动清单、loader 状态或客户端连接。 + +## 启动流程 + +```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 并上传 ELF + C->>S: 连接串口 WebSocket(自动上电) + L->>S: POST /api/v1/loaders/poll + S-->>L: boot + session_id + boot_id + 摘要 + L->>S: accepted / downloading + L->>S: GET 相对 kernel_path + 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 状态一起删除。 + +## 发现和注册代次 + +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` 格式和可选入口符号。状态更新由 `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" +``` + +MAC 保存为小写六字节冒号格式并全局唯一。HTTP Boot 板卡缺少 MAC 时配置无效。只有板卡处于 `idle` 时才允许修改 MAC、重命名或删除;使用中和释放中返回 `409 Conflict`。重复绑定返回错误码 `mac_already_bound`。 + +管理页面的未绑定设备区域每 5 秒刷新。选择探测设备只会把 MAC 带入编辑器并展示 IP、架构、loader 版本和 SMBIOS;不会自动填写板卡 ID、`board_type`、电源、串口或启动设置。也可以手工输入 MAC。 + +## ostool CLI 行为 + +HTTP Boot runner 按以下顺序工作: + +1. 按人工配置的 `board_type` 创建 Session; +2. 上传 ELF,server 发布新的 `boot_id`; +3. 立即连接串口 WebSocket,由现有串口生命周期自动上电; +4. 并行读取原始串口并轮询 loader status; +5. 活动 Session 内板卡重启时继续等待新注册代次,不重建 Session; +6. loader 报告 `failed` 时结束;正常成功仍以目标系统串口成功条件为准; +7. WebSocket 关闭后沿用 `SerialClosed` 释放流程并断电。 + +## 内建 QEMU 虚拟板 + +虚拟板默认关闭。第一阶段固定为 `x86_64 + OVMF + q35`,使用 TCG,协议类型仍保留其他架构值。示例 server 配置: + +```toml +[loader_network] +enabled = true +bind_addr = "0.0.0.0:2998" +public_base_url = "http://10.77.0.1:2999" + +[virtual_qemu] +enabled = true +qemu_binary = "/usr/bin/qemu-system-x86_64" +ovmf_code = "/usr/share/OVMF/OVMF_CODE_4M.fd" +ovmf_vars = "/usr/share/OVMF/OVMF_VARS_4M.fd" +axloader_efi = "/opt/ostool/BOOTX64.EFI" +runtime_dir = "/var/lib/ostool-server/qemu" +network_namespace = "ostool-qemu" +bridge = "ostool-br0" +tap_pool = ["ostool-tap0", "ostool-tap1"] +memory_mib = 512 +cpus = 2 +``` + +本地网络环境命令需要创建 network namespace、veth、bridge、TAP 和 dnsmasq,因此应以具备 `CAP_NET_ADMIN` 的身份运行: + +```bash +ostool-server --config /etc/ostool-server/config.toml virtual-lab up +ostool-server --config /etc/ostool-server/config.toml virtual-lab status +ostool-server --config /etc/ostool-server/config.toml virtual-lab down +``` + +默认客户机网段为 `10.77.0.0/24`,server 为 `10.77.0.1`,dnsmasq/bridge 为 `10.77.0.254`,DHCP 池为 `10.77.0.100-200`。`up`、`status` 和 `down` 可以重复调用;`up` 检测已存在环境,`down` 忽略已删除资源。 + +管理页面启动虚拟设备后,QEMU 必须通过真实 UDP/HTTP 流程出现在未绑定列表。绑定时三个位置必须引用同一虚拟设备及其 MAC: + +```toml +[network_identity] +mac_address = "02:aa:bb:cc:dd:ee" + +[serial] +baud_rate = 115200 + +[serial.key] +kind = "qemu" +value = "" + +[power_management] +kind = "qemu" +virtual_device_id = "" + +[boot] +kind = "httpboot" +boot_arch = "x86_64" +``` + +`VirtualBoardManager` 持有 QEMU 子进程、TAP、独立 OVMF VARS、QMP socket 和串口 hub。`On`/`Off` 幂等;关闭先发 QMP `quit`,3 秒后仍未退出才终止进程。串口 hub 不随 QEMU 子进程退出,保留最近 64 KiB 输出,使同一 WebSocket 能跨 `Off → On`。新 QEMU 启动会生成新的 loader 注册代次,并从同一活动 Session 重新取得原 `boot_id`。 + +## 验收顺序 + +1. 启动未绑定 QEMU,确认它通过真实发现出现在管理页面。 +2. 人工填写板卡 ID、名称/类型、QEMU 电源和串口,并选择探测 MAC。 +3. 使用 `ostool` 创建 Session、上传 ELF并连接串口,确认下载、摘要校验、handoff 和串口成功标志。 +4. 保持 WebSocket 和 Session,执行虚拟板 `Off → On`,确认新 `registration_id` 取得相同 `boot_id` 并再次 handoff。 +5. 关闭 WebSocket,确认 Session 经 `releasing` 回到 `idle` 且 QEMU 退出。 +6. 新建 Session 再运行一次,全程不修改绑定。 + +实体板按相同顺序验收首次绑定、活动 Session 内重启、释放和新 Session;差异只在电源与串口后端。 diff --git a/httpboot-protocol/Cargo.toml b/httpboot-protocol/Cargo.toml index 4eddd59f..e7a7ed96 100644 --- a/httpboot-protocol/Cargo.toml +++ b/httpboot-protocol/Cargo.toml @@ -7,13 +7,14 @@ keywords = ["httpboot", "uefi", "bootloader"] license = "MIT OR Apache-2.0" name = "httpboot-protocol" repository = "https://github.com/drivercraft/ostool" -version = "0.1.1" +version = "0.2.0" [features] -default = ["std", "serde"] -serde = ["dep:serde", "dep:serde_json"] -std = [] +default = ["std", "json"] +alloc = [] +json = ["alloc", "dep:serde", "dep:serde_json"] +std = ["alloc", "serde?/std", "serde_json?/std"] [dependencies] -serde = { workspace = true, features = ["derive"], optional = true } -serde_json = { workspace = true, optional = true } +serde = { version = "1", default-features = false, features = ["alloc", "derive"], optional = true } +serde_json = { version = "1", default-features = false, features = ["alloc"], optional = true } diff --git a/httpboot-protocol/src/lib.rs b/httpboot-protocol/src/lib.rs index 40d8b8e0..43f31672 100644 --- a/httpboot-protocol/src/lib.rs +++ b/httpboot-protocol/src/lib.rs @@ -1,12 +1,125 @@ #![cfg_attr(not(feature = "std"), no_std)] -pub const SERIAL_PROTOCOL_VERSION: u16 = 1; -pub const SERIAL_READY_PREFIX: &str = "AXLOADER READY "; -pub const SERIAL_BOOT_PREFIX: &str = "AXLOADER BOOT "; +#[cfg(feature = "alloc")] +extern crate alloc; -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +use core::{fmt, str::FromStr}; + +#[cfg(feature = "alloc")] +use alloc::{string::String, vec::Vec}; + +pub const PROTOCOL_VERSION: u16 = 2; +pub const DISCOVERY_PORT: u16 = 2998; +pub const MAX_DISCOVERY_DATAGRAM_BYTES: usize = 1400; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MacAddress([u8; 6]); + +impl MacAddress { + pub const ZERO: Self = Self([0; 6]); + + pub const fn new(octets: [u8; 6]) -> Self { + Self(octets) + } + + pub const fn octets(self) -> [u8; 6] { + self.0 + } + + pub fn is_zero(self) -> bool { + self.0 == [0; 6] + } +} + +impl fmt::Display for MacAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + self.0[0], self.0[1], self.0[2], self.0[3], self.0[4], self.0[5] + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParseMacAddressError { + InvalidLength, + InvalidOctet, +} + +impl fmt::Display for ParseMacAddressError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidLength => f.write_str("MAC address must contain six hexadecimal octets"), + Self::InvalidOctet => f.write_str("MAC address contains an invalid hexadecimal octet"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for ParseMacAddressError {} + +impl FromStr for MacAddress { + type Err = ParseMacAddressError; + + fn from_str(value: &str) -> Result { + let mut octets = [0_u8; 6]; + let mut parts = value.split(':'); + for octet in &mut octets { + let part = parts.next().ok_or(ParseMacAddressError::InvalidLength)?; + if part.len() != 2 { + return Err(ParseMacAddressError::InvalidOctet); + } + *octet = + u8::from_str_radix(part, 16).map_err(|_| ParseMacAddressError::InvalidOctet)?; + } + if parts.next().is_some() { + return Err(ParseMacAddressError::InvalidLength); + } + Ok(Self(octets)) + } +} + +#[cfg(feature = "json")] +impl serde::Serialize for MacAddress { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.collect_str(self) + } +} + +#[cfg(feature = "json")] +impl<'de> serde::Deserialize<'de> for MacAddress { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct Visitor; + + impl serde::de::Visitor<'_> for Visitor { + type Value = MacAddress; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a six-octet colon-separated MAC address") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + value.parse().map_err(E::custom) + } + } + + deserializer.deserialize_str(Visitor) + } +} + +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "json", serde(rename_all = "snake_case"))] pub enum BootArch { X86_64, Aarch64, @@ -15,36 +128,122 @@ pub enum BootArch { Other, } -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "json", serde(rename_all = "snake_case"))] pub enum ImageFormat { Elf64, } -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SerialReadyMessage<'a> { +#[cfg(feature = "alloc")] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct LoaderHardwareInfo { + pub manufacturer: Option, + pub product: Option, + pub version: Option, + pub serial: Option, +} + +#[cfg(feature = "alloc")] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoaderDiscoveryProbe { pub protocol_version: u16, - pub board: &'a str, + pub mac_address: MacAddress, + pub current_mac_address: MacAddress, pub arch: BootArch, - pub loader_version: Option<&'a str>, + pub loader_version: String, } -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SerialBootOfferMessage<'a> { +#[cfg(feature = "alloc")] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoaderDiscoveryOffer { pub protocol_version: u16, - pub boot_id: &'a str, - pub kernel_url: &'a str, - pub kernel_size: u64, - pub image_format: ImageFormat, + pub server_id: String, + pub control_base_url: String, + pub registration_id: String, + pub expires_in_ms: u64, +} + +#[cfg(feature = "alloc")] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoaderPollRequest { + pub protocol_version: u16, + pub registration_id: String, + pub mac_address: MacAddress, + pub current_mac_address: MacAddress, + pub ip_address: String, pub arch: BootArch, - pub entry_symbol: Option<&'a str>, + pub loader_version: String, + pub hardware: LoaderHardwareInfo, } -#[cfg(feature = "std")] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg(feature = "alloc")] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "json", serde(tag = "state", rename_all = "snake_case"))] +pub enum LoaderPollResponse { + Unbound, + BoundIdle { + board_id: String, + }, + Boot { + board_id: String, + session_id: String, + boot_id: String, + kernel_path: String, + kernel_size: u64, + kernel_sha256: String, + arch: BootArch, + image_format: ImageFormat, + entry_symbol: Option, + }, + Reject { + code: String, + message: String, + retry_after_ms: Option, + }, +} + +#[cfg(feature = "alloc")] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "json", serde(tag = "phase", rename_all = "snake_case"))] +pub enum LoaderStatusPhase { + Accepted, + Downloading { received: u64, total: u64 }, + Verified, + ReadyToHandoff, + Failed { code: String, message: String }, +} + +#[cfg(feature = "alloc")] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoaderStatusReport { + pub protocol_version: u16, + pub registration_id: String, + pub mac_address: MacAddress, + pub session_id: String, + pub boot_id: String, + pub status: LoaderStatusPhase, +} + +#[cfg(feature = "alloc")] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoaderStatusResponse { + pub session_id: String, + pub boot_id: String, + pub registration_id: Option, + pub status: Option, +} + +#[cfg(feature = "alloc")] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct KernelPublishResponse { pub boot_id: String, @@ -53,129 +252,130 @@ pub struct KernelPublishResponse { pub kernel_sha256: Option, } -#[cfg(all(feature = "std", feature = "serde"))] +#[cfg(all(feature = "alloc", feature = "json"))] +pub fn encode_discovery_probe( + probe: &LoaderDiscoveryProbe, +) -> Result, DiscoveryMessageError> { + let bytes = serde_json::to_vec(probe).map_err(DiscoveryMessageError::Json)?; + if bytes.len() > MAX_DISCOVERY_DATAGRAM_BYTES { + return Err(DiscoveryMessageError::TooLarge(bytes.len())); + } + Ok(bytes) +} + +#[cfg(all(feature = "alloc", feature = "json"))] #[derive(Debug)] -pub enum SerialMessageError { - InvalidPrefix(&'static str), +pub enum DiscoveryMessageError { + TooLarge(usize), Json(serde_json::Error), } -#[cfg(all(feature = "std", feature = "serde"))] -impl core::fmt::Display for SerialMessageError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { +#[cfg(all(feature = "alloc", feature = "json"))] +impl fmt::Display for DiscoveryMessageError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidPrefix(prefix) => { - write!( - f, - "serial line does not start with expected prefix `{prefix}`" - ) - } - Self::Json(err) => write!(f, "failed to parse serial message JSON: {err}"), + Self::TooLarge(size) => write!( + f, + "discovery datagram is {size} bytes, limit is {MAX_DISCOVERY_DATAGRAM_BYTES}" + ), + Self::Json(err) => write!(f, "failed to encode discovery datagram: {err}"), } } } -#[cfg(all(feature = "std", feature = "serde"))] -impl std::error::Error for SerialMessageError {} - -#[cfg(all(feature = "std", feature = "serde"))] -impl From for SerialMessageError { - fn from(err: serde_json::Error) -> Self { - Self::Json(err) - } -} - -#[cfg(all(feature = "std", feature = "serde"))] -pub fn render_serial_ready(message: &SerialReadyMessage<'_>) -> Result { - Ok(format!( - "{SERIAL_READY_PREFIX}{}", - serde_json::to_string(message)? - )) -} +#[cfg(feature = "std")] +impl std::error::Error for DiscoveryMessageError {} -#[cfg(all(feature = "std", feature = "serde"))] -pub fn parse_serial_ready(line: &str) -> Result, SerialMessageError> { - let body = line - .trim() - .strip_prefix(SERIAL_READY_PREFIX) - .ok_or(SerialMessageError::InvalidPrefix(SERIAL_READY_PREFIX))?; - Ok(serde_json::from_str(body)?) -} +#[cfg(test)] +mod tests { + use super::*; + use alloc::string::ToString; -#[cfg(all(feature = "std", feature = "serde"))] -pub fn render_serial_boot_offer( - message: &SerialBootOfferMessage<'_>, -) -> Result { - Ok(format!( - "{SERIAL_BOOT_PREFIX}{}", - serde_json::to_string(message)? - )) -} + #[test] + fn normalizes_mac_addresses() { + let mac: MacAddress = "AA:0b:0C:0d:EE:fF".parse().unwrap(); + assert_eq!(mac.to_string(), "aa:0b:0c:0d:ee:ff"); + assert_eq!(mac.octets(), [0xaa, 0x0b, 0x0c, 0x0d, 0xee, 0xff]); + } -#[cfg(all(feature = "std", feature = "serde"))] -pub fn parse_serial_boot_offer( - line: &str, -) -> Result, SerialMessageError> { - let body = line - .trim() - .strip_prefix(SERIAL_BOOT_PREFIX) - .ok_or(SerialMessageError::InvalidPrefix(SERIAL_BOOT_PREFIX))?; - Ok(serde_json::from_str(body)?) -} + #[test] + fn rejects_malformed_mac_addresses() { + for value in [ + "", + "00:11:22:33:44", + "00:11:22:33:44:555", + "gg:11:22:33:44:55", + ] { + assert!(value.parse::().is_err(), "accepted {value}"); + } + } -#[cfg(test)] -mod tests { - use super::{ - BootArch, ImageFormat, SERIAL_BOOT_PREFIX, SERIAL_PROTOCOL_VERSION, SERIAL_READY_PREFIX, - SerialBootOfferMessage, SerialReadyMessage, parse_serial_boot_offer, parse_serial_ready, - render_serial_boot_offer, render_serial_ready, - }; + #[test] + fn serializes_mac_as_canonical_string() { + let mac = MacAddress::new([0, 1, 2, 3, 4, 255]); + assert_eq!( + serde_json::to_string(&mac).unwrap(), + "\"00:01:02:03:04:ff\"" + ); + assert_eq!( + serde_json::from_str::("\"00:01:02:03:04:FF\"").unwrap(), + mac + ); + } #[test] - fn serializes_loader_control_messages() { - let offer = SerialBootOfferMessage { - protocol_version: SERIAL_PROTOCOL_VERSION, - boot_id: "boot-1", - kernel_url: "http://127.0.0.1/kernel.elf", - kernel_size: 4096, - image_format: ImageFormat::Elf64, + fn discovery_packet_stays_below_the_mtu_budget() { + let probe = LoaderDiscoveryProbe { + protocol_version: PROTOCOL_VERSION, + mac_address: "02:00:00:00:00:01".parse().unwrap(), + current_mac_address: "02:00:00:00:00:01".parse().unwrap(), arch: BootArch::X86_64, - entry_symbol: Some("httpboot_entry"), + loader_version: "axloader-0.2".into(), }; - let value = serde_json::to_value(&offer).unwrap(); - assert_eq!(value["image_format"], "elf64"); + assert!(encode_discovery_probe(&probe).unwrap().len() <= MAX_DISCOVERY_DATAGRAM_BYTES); } #[test] - fn renders_and_parses_serial_ready_message() { - let ready = SerialReadyMessage { - protocol_version: SERIAL_PROTOCOL_VERSION, - board: "asus-nuc15crh", + fn rejects_discovery_packet_above_the_mtu_budget() { + let probe = LoaderDiscoveryProbe { + protocol_version: PROTOCOL_VERSION, + mac_address: "02:00:00:00:00:01".parse().unwrap(), + current_mac_address: "02:00:00:00:00:01".parse().unwrap(), arch: BootArch::X86_64, - loader_version: Some("axloader"), + loader_version: "x".repeat(MAX_DISCOVERY_DATAGRAM_BYTES), }; - let line = render_serial_ready(&ready).unwrap(); + assert!(matches!( + encode_discovery_probe(&probe), + Err(DiscoveryMessageError::TooLarge(_)) + )); + } - assert!(line.starts_with(SERIAL_READY_PREFIX)); - assert_eq!(parse_serial_ready(&line).unwrap(), ready); + #[test] + fn rejects_malformed_discovery_json() { + assert!(serde_json::from_slice::(b"{not-json}").is_err()); + assert!( + serde_json::from_slice::(br#"{"protocol_version":2}"#).is_err() + ); } #[test] - fn renders_and_parses_serial_boot_offer_message() { - let offer = SerialBootOfferMessage { - protocol_version: SERIAL_PROTOCOL_VERSION, - boot_id: "boot-1", - kernel_url: "http://10.3.10.192:2999/boot/kernel.elf", + fn loader_poll_state_round_trips() { + let response = LoaderPollResponse::Boot { + board_id: "qemu-x86-1".into(), + session_id: "session-1".into(), + boot_id: "boot-1".into(), + kernel_path: "/boot/sessions/session-1/kernel.elf".into(), kernel_size: 4096, - image_format: ImageFormat::Elf64, + kernel_sha256: "00".repeat(32), arch: BootArch::X86_64, - entry_symbol: Some("httpboot_entry"), + image_format: ImageFormat::Elf64, + entry_symbol: Some("httpboot_entry".into()), }; - - let line = render_serial_boot_offer(&offer).unwrap(); - - assert!(line.starts_with(SERIAL_BOOT_PREFIX)); - assert_eq!(parse_serial_boot_offer(&line).unwrap(), offer); + let bytes = serde_json::to_vec(&response).unwrap(); + assert_eq!( + serde_json::from_slice::(&bytes).unwrap(), + response + ); } } diff --git a/ostool-server/Cargo.toml b/ostool-server/Cargo.toml index b0e32b21..02ac53d5 100644 --- a/ostool-server/Cargo.toml +++ b/ostool-server/Cargo.toml @@ -31,6 +31,7 @@ chrono = {version = "0.4.44", features = ["serde"]} clap = {workspace = true, features = ["derive"]} env_logger = {workspace = true} futures-util = "0.3" +fs4 = "1.1" httpboot-protocol = { workspace = true } log = {workspace = true} mime_guess = "2" diff --git a/ostool-server/README.md b/ostool-server/README.md index 5487fcae..2396b23e 100644 --- a/ostool-server/README.md +++ b/ostool-server/README.md @@ -106,10 +106,10 @@ is disabled. Upload responses include a board-reachable `http_url`; both the file and URL expire when the session is released or times out. For boards using the UEFI HTTP Boot loader, configure the board boot profile -with `kind = "httpboot"` and, when needed, `boot_arch`. The server uses the -allocated board session and that board's serial configuration to send the boot -offer to axloader; the board NIC MAC address is not part of the current control -flow. +with `kind = "httpboot"`, `network_identity.mac_address`, and, when needed, +`boot_arch`. The server binds each UDP/HTTP loader registration to the board by +its persisted permanent MAC address. Boot manifests and status reports stay in +the active session; serial is used only for target-system interaction. ## Useful Commands diff --git a/ostool-server/src/api/error.rs b/ostool-server/src/api/error.rs index 551b5e74..a0c337b7 100644 --- a/ostool-server/src/api/error.rs +++ b/ostool-server/src/api/error.rs @@ -38,6 +38,10 @@ impl ApiError { Self::new(StatusCode::CONFLICT, "conflict", message) } + pub fn mac_already_bound(message: impl Into) -> Self { + Self::new(StatusCode::CONFLICT, "mac_already_bound", message) + } + pub fn service_unavailable(message: impl Into) -> Self { Self::new( StatusCode::SERVICE_UNAVAILABLE, diff --git a/ostool-server/src/api/models.rs b/ostool-server/src/api/models.rs index 518dec53..2ac91d12 100644 --- a/ostool-server/src/api/models.rs +++ b/ostool-server/src/api/models.rs @@ -1,12 +1,13 @@ use chrono::{DateTime, Utc}; -pub use httpboot_protocol::KernelPublishResponse; +use httpboot_protocol::{BootArch, LoaderHardwareInfo, MacAddress}; +pub use httpboot_protocol::{KernelPublishResponse, LoaderStatusResponse}; use serde::{Deserialize, Serialize}; use url::Url; use crate::{ config::{ - BoardConfig, BootConfig, PowerManagementConfig, SerialConfig, SerialPortKeyKind, - TftpConfig, TftpNetworkConfig, UploadLimitsConfig, + BoardConfig, BoardNetworkIdentity, BootConfig, PowerManagementConfig, SerialConfig, + SerialPortKeyKind, TftpConfig, TftpNetworkConfig, UploadLimitsConfig, }, dtb_store::DtbFile, session::Session, @@ -93,6 +94,45 @@ pub struct AdminBoardUpsertRequest { pub serial: Option, pub power_management: PowerManagementConfig, pub boot: BootConfig, + #[serde(default)] + pub network_identity: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoaderDeviceSummary { + pub mac_address: MacAddress, + pub current_mac_address: MacAddress, + pub ip_address: String, + pub arch: BootArch, + pub loader_version: String, + pub hardware: LoaderHardwareInfo, + pub last_seen_at: DateTime, + pub online: bool, + pub conflict: bool, + pub bound_board_id: Option, + pub current_registration_id: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CreateVirtualDeviceRequest { + #[serde(default)] + pub mac_address: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VirtualDeviceSummary { + pub id: String, + pub mac_address: MacAddress, + pub tap: String, + pub powered: bool, + pub serial_connected: bool, + pub generation: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VirtualDevicesResponse { + pub enabled: bool, + pub devices: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/ostool-server/src/api/router.rs b/ostool-server/src/api/router.rs index 914a4967..4d8c238c 100644 --- a/ostool-server/src/api/router.rs +++ b/ostool-server/src/api/router.rs @@ -9,7 +9,10 @@ use axum::{ routing::{delete, get, post, put}, }; use futures_util::future::join_all; -use httpboot_protocol::{BootArch, ImageFormat}; +use httpboot_protocol::{ + BootArch, ImageFormat, LoaderPollRequest, LoaderPollResponse, LoaderStatusReport, + LoaderStatusResponse, MacAddress, +}; use mime_guess::from_path; use sha2::{Digest, Sha256}; use std::sync::Arc; @@ -27,11 +30,12 @@ use crate::{ AdminServerConfigEditable, AdminServerConfigReadonly, AdminServerConfigResponse, AdminSessionsResponse, AdminTftpConfigResponse, AdminTftpStatusResponse, BoardPowerAction, BoardPowerStatusResponse, BoardRuntimeStatusResponse, - BoardTypeSummary, BootProfileResponse, CreateSessionRequest, DtbFileResponse, - HeartbeatResponse, HttpBootFileResponse, KernelPublishResponse, - NetworkInterfaceSummary, SerialPortSummary, SerialStatusResponse, - SessionCreatedResponse, SessionDetailResponse, SessionDtbResponse, - SharedSessionFileResponse, TftpSessionResponse, UpdateServerConfigRequest, + BoardTypeSummary, BootProfileResponse, CreateSessionRequest, + CreateVirtualDeviceRequest, DtbFileResponse, HeartbeatResponse, HttpBootFileResponse, + KernelPublishResponse, LoaderDeviceSummary, NetworkInterfaceSummary, SerialPortSummary, + SerialStatusResponse, SessionCreatedResponse, SessionDetailResponse, + SessionDtbResponse, SharedSessionFileResponse, TftpSessionResponse, + UpdateServerConfigRequest, VirtualDeviceSummary, VirtualDevicesResponse, }, }, board_pool::BoardAllocationStatus, @@ -40,6 +44,7 @@ use crate::{ }, dtb_store::normalize_dtb_name, http_boot::publish::{KernelPublishInput, publish_kernel}, + loader::RegistrationError, power::{PowerAction, PowerActionError}, serial::{ discovery::list_serial_ports as discover_serial_ports, @@ -50,8 +55,8 @@ use crate::{ }, ws::run_serial_ws, }, - session::SessionState, session::SessionStopReason, + session::{LoaderStatusUpdateError, SessionBootCommand, SessionState}, state::{AppState, BoardLeaseState, TouchSessionError}, tftp::{ files::{TftpFileRef, normalize_relative_path}, @@ -83,6 +88,17 @@ pub fn build_router(state: AppState) -> Router { ) .route("/api/v1/admin/overview", get(get_admin_overview)) .route("/api/v1/admin/boards", get(list_boards).post(create_board)) + .route("/api/v1/admin/loader-devices", get(list_loader_devices)) + .route( + "/api/v1/admin/virtual-devices", + get(list_virtual_devices).post(create_virtual_device), + ) + .route( + "/api/v1/admin/virtual-devices/{device_id}", + delete(delete_virtual_device), + ) + .route("/api/v1/loaders/poll", post(poll_loader)) + .route("/api/v1/loaders/status", post(report_loader_status)) .route("/api/v1/admin/dtbs", get(list_dtbs).post(create_dtb)) .route("/api/v1/admin/serial-ports", get(list_serial_ports)) .route( @@ -97,6 +113,10 @@ pub fn build_router(state: AppState) -> Router { "/api/v1/admin/boards/{board_id}/runtime-status", get(get_board_runtime_status), ) + .route( + "/api/v1/sessions/{session_id}/loader-status", + get(get_loader_status), + ) .route( "/api/v1/admin/boards/{board_id}", get(get_board).put(update_board).delete(delete_board), @@ -354,7 +374,10 @@ async fn create_board( State(state): State, axum::Json(request): axum::Json, ) -> Result<(StatusCode, axum::Json), ApiError> { + let _inventory_guard = state.board_inventory_gate.lock().await; let board = build_board_config_for_create(&state, request).await?; + validate_board_config(&board)?; + validate_virtual_binding(&state, &board, true).await?; { let boards = state.boards.read().await; @@ -364,6 +387,7 @@ async fn create_board( board.id ))); } + ensure_unique_network_identity(&boards, &board, None)?; } state.board_store.write_board(&board).await?; @@ -381,7 +405,14 @@ async fn update_board( State(state): State, axum::Json(request): axum::Json, ) -> Result, ApiError> { + let _inventory_guard = state.board_inventory_gate.lock().await; let board = build_board_config_for_update(&state, &board_id, request).await?; + validate_board_config(&board)?; + let existing_board = state.boards.read().await.get(&board_id).cloned(); + let changes_virtual_binding = existing_board + .as_ref() + .is_none_or(|existing| virtual_binding(existing) != virtual_binding(&board)); + validate_virtual_binding(&state, &board, changes_virtual_binding).await?; { let boards = state.boards.read().await; @@ -394,6 +425,7 @@ async fn update_board( board.id ))); } + ensure_unique_network_identity(&boards, &board, Some(&board_id))?; } let runtime = state @@ -407,8 +439,17 @@ async fn update_board( } state.board_store.write_board(&board).await?; - if board.id != board_id { - state.board_store.delete_board(&board_id).await?; + if board.id != board_id + && let Err(error) = state.board_store.delete_board(&board_id).await + { + if let Err(rollback_error) = state.board_store.delete_board(&board.id).await { + return Err(ApiError::internal(format!( + "failed to remove old board config `{board_id}`: {error:#}; \ + failed to roll back new board config `{}`: {rollback_error:#}", + board.id + ))); + } + return Err(error.into()); } { @@ -421,6 +462,386 @@ async fn update_board( Ok(axum::Json(board)) } +fn ensure_unique_network_identity( + boards: &BTreeMap, + candidate: &BoardConfig, + replaced_board_id: Option<&str>, +) -> Result<(), ApiError> { + let Some(identity) = candidate.network_identity.as_ref() else { + return Ok(()); + }; + if let Some((board_id, _)) = boards.iter().find(|(board_id, board)| { + Some(board_id.as_str()) != replaced_board_id + && board + .network_identity + .as_ref() + .is_some_and(|existing| existing.mac_address == identity.mac_address) + }) { + return Err(ApiError::mac_already_bound(format!( + "MAC {} is already bound to board `{board_id}`", + identity.mac_address + ))); + } + Ok(()) +} + +fn validate_board_config(board: &BoardConfig) -> Result<(), ApiError> { + board + .validate() + .map_err(|error| ApiError::bad_request(format!("{error:#}"))) +} + +async fn validate_virtual_binding( + state: &AppState, + board: &BoardConfig, + require_discovery: bool, +) -> Result<(), ApiError> { + let PowerManagementConfig::Qemu { virtual_device_id } = &board.power_management else { + return Ok(()); + }; + let device = state + .virtual_boards + .snapshot(virtual_device_id) + .await + .ok_or_else(|| { + ApiError::bad_request(format!( + "virtual device `{virtual_device_id}` does not exist" + )) + })?; + let board_mac = board + .network_identity + .as_ref() + .expect("validated QEMU board has a network identity") + .mac_address; + if device.mac_address != board_mac { + return Err(ApiError::bad_request(format!( + "virtual device `{virtual_device_id}` has MAC {}, not {board_mac}", + device.mac_address + ))); + } + if require_discovery + && !state + .loader_registry + .snapshots() + .await + .iter() + .any(|loader| virtual_loader_is_bindable(loader, board_mac)) + { + return Err(ApiError::new( + StatusCode::CONFLICT, + "virtual_device_not_discovered", + format!( + "virtual device `{virtual_device_id}` has not completed real UDP/HTTP discovery" + ), + )); + } + Ok(()) +} + +fn virtual_loader_is_bindable( + loader: &crate::loader::LoaderDeviceSnapshot, + board_mac: MacAddress, +) -> bool { + loader.mac_address == board_mac && loader.online && !loader.conflict +} + +fn virtual_binding(board: &BoardConfig) -> Option<(&str, MacAddress)> { + let PowerManagementConfig::Qemu { virtual_device_id } = &board.power_management else { + return None; + }; + Some(( + virtual_device_id, + board.network_identity.as_ref()?.mac_address, + )) +} + +async fn list_loader_devices( + State(state): State, +) -> Result>, ApiError> { + let boards = state.boards.read().await; + let snapshots = state.loader_registry.snapshots().await; + Ok(axum::Json( + snapshots + .into_iter() + .map(|device| LoaderDeviceSummary { + bound_board_id: board_id_for_mac(&boards, device.mac_address), + mac_address: device.mac_address, + current_mac_address: device.current_mac_address, + ip_address: device.ip_address, + arch: device.arch, + loader_version: device.loader_version, + hardware: device.hardware, + last_seen_at: device.last_seen_at, + online: device.online, + conflict: device.conflict, + current_registration_id: device.current_registration_id, + }) + .collect(), + )) +} + +async fn list_virtual_devices(State(state): State) -> axum::Json { + let devices = state + .virtual_boards + .snapshots() + .await + .into_iter() + .map(virtual_device_summary) + .collect(); + axum::Json(VirtualDevicesResponse { + enabled: state.virtual_boards.enabled(), + devices, + }) +} + +async fn create_virtual_device( + State(state): State, + axum::Json(request): axum::Json, +) -> Result<(StatusCode, axum::Json), ApiError> { + let device = state + .virtual_boards + .create_device(request.mac_address) + .await + .map_err(|error| ApiError::service_unavailable(format!("{error:#}")))?; + Ok(( + StatusCode::CREATED, + axum::Json(virtual_device_summary(device)), + )) +} + +async fn delete_virtual_device( + Path(device_id): Path, + State(state): State, +) -> Result { + let _inventory_guard = state.board_inventory_gate.lock().await; + let boards = state.boards.read().await; + if let Some(board) = boards.values().find(|board| { + matches!( + &board.power_management, + PowerManagementConfig::Qemu { virtual_device_id } if virtual_device_id == &device_id + ) + }) { + return Err(ApiError::conflict(format!( + "virtual device `{device_id}` is bound to board `{}`", + board.id + ))); + } + drop(boards); + state + .virtual_boards + .delete_device(&device_id) + .await + .map_err(|error| ApiError::not_found(format!("{error:#}")))?; + Ok(StatusCode::NO_CONTENT) +} + +fn virtual_device_summary( + snapshot: crate::virtual_qemu::VirtualDeviceSnapshot, +) -> VirtualDeviceSummary { + VirtualDeviceSummary { + id: snapshot.id, + mac_address: snapshot.mac_address, + tap: snapshot.tap, + powered: snapshot.powered, + serial_connected: snapshot.serial_connected, + generation: snapshot.generation, + } +} + +async fn poll_loader( + State(state): State, + request: Request, +) -> Result, ApiError> { + let request: LoaderPollRequest = parse_loader_json(request).await?; + let conflict = match state.loader_registry.accept_poll(&request).await { + Ok(conflict) => conflict, + Err(error) => return Ok(axum::Json(loader_reject(error))), + }; + if conflict { + return Ok(axum::Json(LoaderPollResponse::Reject { + code: "duplicate_mac".into(), + message: "another active loader is reporting the same permanent MAC".into(), + retry_after_ms: Some(2_000), + })); + } + + let boards = state.boards.read().await; + let Some(board_id) = board_id_for_mac(&boards, request.mac_address) else { + return Ok(axum::Json(LoaderPollResponse::Unbound)); + }; + drop(boards); + + let Some(runtime) = state.board_runtime_status(&board_id).await else { + return Ok(axum::Json(LoaderPollResponse::Reject { + code: "board_unavailable".into(), + message: "the bound board has no runtime state".into(), + retry_after_ms: Some(2_000), + })); + }; + let Some(session_id) = runtime.active_session_id else { + return Ok(axum::Json(LoaderPollResponse::BoundIdle { board_id })); + }; + if runtime.lease_state != BoardLeaseState::Using { + return Ok(axum::Json(LoaderPollResponse::Reject { + code: "board_releasing".into(), + message: "the bound board is being released".into(), + retry_after_ms: Some(2_000), + })); + } + let Some(session) = state.session_state(&session_id).await else { + return Ok(axum::Json(LoaderPollResponse::Reject { + code: "session_unavailable".into(), + message: "the active session is unavailable".into(), + retry_after_ms: Some(2_000), + })); + }; + let Some(command) = session.boot_command().await else { + return Ok(axum::Json(LoaderPollResponse::BoundIdle { board_id })); + }; + if command.arch != request.arch { + return Ok(axum::Json(LoaderPollResponse::Reject { + code: "architecture_mismatch".into(), + message: "the published kernel architecture does not match this loader".into(), + retry_after_ms: None, + })); + } + Ok(axum::Json(LoaderPollResponse::Boot { + board_id, + session_id, + boot_id: command.boot_id, + kernel_path: command.kernel_path, + kernel_size: command.kernel_size, + kernel_sha256: command.kernel_sha256, + arch: command.arch, + image_format: command.image_format, + entry_symbol: command.entry_symbol, + })) +} + +async fn report_loader_status( + State(state): State, + request: Request, +) -> Result { + let report: LoaderStatusReport = parse_loader_json(request).await?; + state + .loader_registry + .accept_status(&report) + .await + .map_err(registration_api_error)?; + let session = active_session_state_or_404(&state, &report.session_id).await?; + if session + .board() + .network_identity + .as_ref() + .is_none_or(|identity| identity.mac_address != report.mac_address) + { + return Err(ApiError::new( + StatusCode::CONFLICT, + "loader_board_mismatch", + "loader MAC does not own this session", + )); + } + session + .update_loader_status(report.registration_id, &report.boot_id, report.status) + .await + .map_err(|error| match error { + LoaderStatusUpdateError::NoBootCommand => { + ApiError::conflict("session has no published boot command") + } + LoaderStatusUpdateError::StaleBoot => ApiError::new( + StatusCode::CONFLICT, + "stale_boot_id", + "status belongs to a superseded boot command", + ), + })?; + Ok(StatusCode::NO_CONTENT) +} + +async fn parse_loader_json( + request: Request, +) -> Result { + let body = to_bytes(request.into_body(), 64 * 1024) + .await + .map_err(|_| ApiError::bad_request("failed to read loader JSON body"))?; + serde_json::from_slice(&body) + .map_err(|error| ApiError::bad_request(format!("invalid loader JSON: {error}"))) +} + +async fn get_loader_status( + Path(session_id): Path, + State(state): State, +) -> Result, ApiError> { + let session = state + .session_state(&session_id) + .await + .ok_or_else(|| ApiError::not_found(format!("session `{session_id}` not found")))?; + session + .loader_status() + .await + .map(axum::Json) + .ok_or_else(|| ApiError::not_found("session has no published boot command")) +} + +fn board_id_for_mac(boards: &BTreeMap, mac: MacAddress) -> Option { + boards.iter().find_map(|(board_id, board)| { + board + .network_identity + .as_ref() + .filter(|identity| identity.mac_address == mac) + .map(|_| board_id.clone()) + }) +} + +fn loader_reject(error: RegistrationError) -> LoaderPollResponse { + let (code, message) = match error { + RegistrationError::ProtocolVersion => { + ("unsupported_protocol", "unsupported protocol version") + } + RegistrationError::Unknown => ("unknown_registration", "registration is unknown"), + RegistrationError::Expired => ("expired_registration", "registration has expired"), + RegistrationError::MacMismatch => ( + "registration_mac_mismatch", + "registration belongs to another MAC", + ), + RegistrationError::Replaced => ( + "replaced_registration", + "registration was replaced by a newer loader", + ), + RegistrationError::Conflict => ( + "duplicate_mac", + "multiple active loaders report the same MAC", + ), + }; + LoaderPollResponse::Reject { + code: code.into(), + message: message.into(), + retry_after_ms: Some(2_000), + } +} + +fn registration_api_error(error: RegistrationError) -> ApiError { + let (code, message) = match error { + RegistrationError::ProtocolVersion => { + ("unsupported_protocol", "unsupported protocol version") + } + RegistrationError::Unknown => ("unknown_registration", "registration is unknown"), + RegistrationError::Expired => ("expired_registration", "registration has expired"), + RegistrationError::MacMismatch => ( + "registration_mac_mismatch", + "registration belongs to another MAC", + ), + RegistrationError::Replaced => ( + "replaced_registration", + "registration was replaced by a newer loader", + ), + RegistrationError::Conflict => ( + "duplicate_mac", + "multiple active loaders report the same MAC", + ), + }; + ApiError::new(StatusCode::CONFLICT, code, message) +} + async fn build_board_config_for_create( state: &AppState, request: AdminBoardUpsertRequest, @@ -458,6 +879,12 @@ fn normalize_board_upsert_request( normalize_power_management_config(&mut request.power_management)?; normalize_boot_config(&mut request.boot)?; + if matches!(request.boot, BootConfig::UefiHttp(_)) && request.network_identity.is_none() { + return Err(ApiError::bad_request( + "network_identity.mac_address is required for httpboot boards", + )); + } + if let Some(id) = request.id.as_ref() && (id.contains('/') || id.contains('\\')) { @@ -550,6 +977,9 @@ fn normalize_power_management_config( PowerManagementConfig::ZhongshengRelay(relay) => { normalize_serial_key_value(&mut relay.key, "power_management.key.value")?; } + PowerManagementConfig::Qemu { virtual_device_id } => { + normalize_required_string(virtual_device_id, "power_management.virtual_device_id")?; + } } Ok(()) @@ -582,9 +1012,7 @@ fn normalize_boot_config(boot: &mut BootConfig) -> Result<(), ApiError> { BootConfig::Pxe(profile) => { normalize_optional_string(&mut profile.notes); } - BootConfig::UefiHttp(profile) => { - profile.mac = None; - } + BootConfig::UefiHttp(_) => {} } Ok(()) } @@ -609,6 +1037,7 @@ impl AdminBoardUpsertRequest { serial: self.serial, power_management: self.power_management, boot: self.boot, + network_identity: self.network_identity, notes: self.notes, disabled: self.disabled, } @@ -707,6 +1136,7 @@ async fn delete_board( Path(board_id): Path, State(state): State, ) -> Result { + let _inventory_guard = state.board_inventory_gate.lock().await; let runtime = state .board_runtime_status(&board_id) .await @@ -717,15 +1147,12 @@ async fn delete_board( ))); } - { - let mut boards = state.boards.write().await; - if boards.remove(&board_id).is_none() { - return Err(ApiError::not_found(format!("board `{board_id}` not found"))); - } + if !state.boards.read().await.contains_key(&board_id) { + return Err(ApiError::not_found(format!("board `{board_id}` not found"))); } - state.sync_board_runtime_states().await; - state.board_store.delete_board(&board_id).await?; + state.boards.write().await.remove(&board_id); + state.sync_board_runtime_states().await; Ok(StatusCode::NO_CONTENT) } @@ -1075,12 +1502,28 @@ async fn get_serial_status( .map(|session| session.serial_connected) .unwrap_or(false); let response = if let Some(serial) = board.serial { - let resolved = resolve_serial_config(&serial) - .map_err(|err| ApiError::service_unavailable(format!("{err:#}")))?; + let port = if serial.key.kind == crate::config::SerialPortKeyKind::Qemu { + if state + .virtual_boards + .snapshot(&serial.key.value) + .await + .is_none() + { + return Err(ApiError::service_unavailable(format!( + "virtual device `{}` is unavailable", + serial.key.value + ))); + } + format!("qemu:{}", serial.key.value) + } else { + resolve_serial_config(&serial) + .map_err(|err| ApiError::service_unavailable(format!("{err:#}")))? + .current_device_path + }; SerialStatusResponse { available: true, connected, - port: Some(resolved.current_device_path), + port: Some(port), baud_rate: Some(serial.baud_rate), ws_url: Some(format!("/api/v1/sessions/{session_id}/serial/ws")), } @@ -1097,11 +1540,14 @@ async fn get_serial_status( } fn with_resolved_serial_config(mut board: BoardConfig) -> BoardConfig { - if let Some(serial) = board.serial.as_mut() - && let Ok(resolved) = resolve_serial_config(serial) - { - serial.resolved_device_path = Some(resolved.current_device_path); - serial.resolved_usb_path = resolved.usb_path; + if let Some(serial) = board.serial.as_mut() { + if serial.key.kind == crate::config::SerialPortKeyKind::Qemu { + serial.resolved_device_path = Some(format!("qemu:{}", serial.key.value)); + serial.resolved_usb_path = None; + } else if let Ok(resolved) = resolve_serial_config(serial) { + serial.resolved_device_path = Some(resolved.current_device_path); + serial.resolved_usb_path = resolved.usb_path; + } } board @@ -1247,7 +1693,17 @@ async fn read_session_file_response( let body = fs::read(&disk_path) .await .map_err(|err| ApiError::from(anyhow::Error::from(err)))?; - Ok(([(header::CONTENT_TYPE, content_type)], body).into_response()) + let content_length = HeaderValue::from_str(&body.len().to_string()).map_err(|err| { + ApiError::service_unavailable(format!("invalid content length header: {err}")) + })?; + Ok(( + [ + (header::CONTENT_TYPE, content_type), + (header::CONTENT_LENGTH, content_length), + ], + body, + ) + .into_response()) } async fn read_file_range( @@ -1327,9 +1783,9 @@ async fn put_http_boot_kernel( let remote_name = optional_header(headers, "X-HttpBoot-Remote-Name")? .unwrap_or_else(|| "kernel.elf".to_string()); let remote_name = parse_relative_path(&remote_name)?; - let _arch = parse_httpboot_arch_header(headers)?; - let _image_format = parse_httpboot_image_format_header(headers)?; - let _entry_symbol = optional_header(headers, "X-HttpBoot-Entry-Symbol")? + let arch = parse_httpboot_arch_header(headers)?; + let image_format = parse_httpboot_image_format_header(headers)?; + let entry_symbol = optional_header(headers, "X-HttpBoot-Entry-Symbol")? .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); if !state.config.read().await.http_boot.enabled { @@ -1351,6 +1807,21 @@ async fn put_http_boot_kernel( kernel_size, kernel_sha256: Some(kernel_sha256), }); + let kernel_path = kernel_response.http_url.path().to_string(); + session + .publish_boot_command(SessionBootCommand { + boot_id: response.boot_id.clone(), + kernel_path, + kernel_size, + kernel_sha256: response + .kernel_sha256 + .clone() + .expect("kernel upload always computes SHA-256"), + arch, + image_format, + entry_symbol, + }) + .await; Ok((StatusCode::CREATED, axum::Json(response))) } @@ -1708,7 +2179,7 @@ fn http_boot_url( Ok(url) } -fn http_boot_public_base_url(config: &ServerConfig) -> Result { +pub(crate) fn http_boot_public_base_url(config: &ServerConfig) -> Result { if let Some(public_base_url) = config.http_boot.public_base_url.as_deref() && !public_base_url.trim().is_empty() { @@ -2116,6 +2587,10 @@ mod tests { body::{Body, to_bytes}, http::{Request, StatusCode, header}, }; + use httpboot_protocol::{ + BootArch, LoaderDiscoveryProbe, LoaderHardwareInfo, LoaderPollRequest, LoaderPollResponse, + LoaderStatusPhase, LoaderStatusReport, PROTOCOL_VERSION, + }; use serde::Serialize; use serde_json::json; #[cfg(unix)] @@ -2131,7 +2606,7 @@ mod tests { use super::{ DTB_UPLOAD_MAX_MIB, ResolvedNetwork, boot_profile_with_resolved_network, build_router, - http_boot_url, mib_to_bytes, resolve_server_network, + http_boot_url, mib_to_bytes, resolve_server_network, virtual_loader_is_bindable, }; use crate::{ api::models::{ @@ -2174,6 +2649,10 @@ mod tests { dtb_dir: root.join("dtbs"), tftp: TftpConfig::Builtin(BuiltinTftpConfig::default_with_root(root.join("tftp"))), http_boot: crate::config::HttpBootConfig::default_with_root(root.join("http-boot")), + loader_network: crate::config::LoaderNetworkConfig::default(), + virtual_qemu: crate::config::VirtualQemuConfig::default_with_runtime_dir( + root.join("qemu"), + ), network: crate::TftpNetworkConfig { interface: "lo".into(), }, @@ -2186,6 +2665,13 @@ mod tests { } async fn test_router_with_config(update: impl FnOnce(&mut ServerConfig)) -> Router { + let (router, _) = test_router_and_state_with_config(update).await; + router + } + + async fn test_router_and_state_with_config( + update: impl FnOnce(&mut ServerConfig), + ) -> (Router, crate::AppState) { let temp = tempdir().unwrap(); let root = temp.path().to_path_buf(); std::mem::forget(temp); @@ -2195,7 +2681,7 @@ mod tests { let manager: Arc = build_tftp_manager(&config.tftp); let state = build_app_state(config_path, config, manager).await.unwrap(); state.ensure_data_dirs().await.unwrap(); - build_router(state) + (build_router(state.clone()), state) } async fn create_board(app: &Router, request: impl Serialize) -> StatusCode { @@ -2274,6 +2760,7 @@ mod tests { use_tftp: true, ..Default::default() }), + network_identity: None, notes: Some("rack-a".into()), disabled: false, } @@ -2285,7 +2772,9 @@ mod tests { board.tags = vec!["uefi-http".into()]; board.boot = BootConfig::UefiHttp(UefiHttpProfile { boot_arch: Some(UefiBootArch::X86_64), - mac: None, + }); + board.network_identity = Some(crate::config::BoardNetworkIdentity { + mac_address: "02:00:00:00:00:01".parse().unwrap(), }); board } @@ -2485,6 +2974,7 @@ mod tests { use_tftp: true, ..Default::default() }), + network_identity: None, notes: None, disabled: false, }; @@ -2572,7 +3062,7 @@ mod tests { } #[tokio::test] - async fn create_httpboot_board_keeps_arch_and_drops_unused_mac_field() { + async fn create_httpboot_board_persists_canonical_network_identity() { let app = test_router().await; let response = app .clone() @@ -2594,8 +3084,10 @@ mod tests { }, "boot": { "kind": "httpboot", - "boot_arch": "x86_64", - "mac": "1C-69-7A-DC-F3-47" + "boot_arch": "x86_64" + }, + "network_identity": { + "mac_address": "1C:69:7A:DC:F3:47" }, "notes": null, "disabled": false @@ -2614,7 +3106,162 @@ mod tests { panic!("expected httpboot"); }; assert_eq!(profile.boot_arch, Some(UefiBootArch::X86_64)); - assert_eq!(profile.mac, None); + assert_eq!( + board.network_identity.unwrap().mac_address.to_string(), + "1c:69:7a:dc:f3:47" + ); + } + + #[tokio::test] + async fn create_board_rejects_duplicate_mac_with_stable_error_code() { + let app = test_router().await; + let first = sample_httpboot_board("uefi-http-01"); + assert_eq!( + create_board(&app, serde_json::to_value(&first).unwrap()).await, + StatusCode::CREATED + ); + let mut second = sample_httpboot_board("uefi-http-02"); + second.board_type = "another-pool".into(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/admin/boards") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(&second).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CONFLICT); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let error: crate::api::models::ErrorResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(error.code, "mac_already_bound"); + } + + #[tokio::test] + async fn virtual_device_list_reports_disabled_without_starting_qemu() { + let app = test_router().await; + let response = app + .oneshot( + Request::builder() + .uri("/api/v1/admin/virtual-devices") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let virtual_devices: crate::api::models::VirtualDevicesResponse = + serde_json::from_slice(&body).unwrap(); + assert!(!virtual_devices.enabled); + assert!(virtual_devices.devices.is_empty()); + } + + #[tokio::test] + async fn qemu_board_binding_requires_real_loader_discovery() { + let (app, state) = test_router_and_state_with_config(|config| { + config.virtual_qemu.enabled = true; + config.virtual_qemu.tap_pool = vec!["test-tap".into()]; + }) + .await; + let virtual_id = "virtual-discovery-test"; + let mac = "02:00:00:00:00:42".parse().unwrap(); + state + .virtual_boards + .insert_test_device(virtual_id, mac) + .await + .unwrap(); + let board = json!({ + "id": "qemu-discovery-test", + "board_type": "qemu-x86_64", + "tags": [], + "serial": { + "key": { "kind": "qemu", "value": virtual_id }, + "baud_rate": 115200 + }, + "power_management": { "kind": "qemu", "virtual_device_id": virtual_id }, + "boot": { "kind": "httpboot", "boot_arch": "x86_64" }, + "network_identity": { "mac_address": mac }, + "notes": null, + "disabled": false + }); + + let rejected = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/admin/boards") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(&board).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::CONFLICT); + let body_bytes = to_bytes(rejected.into_body(), usize::MAX).await.unwrap(); + let error: crate::api::models::ErrorResponse = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(error.code, "virtual_device_not_discovered"); + + let offer = state + .loader_registry + .offer( + &LoaderDiscoveryProbe { + protocol_version: PROTOCOL_VERSION, + mac_address: mac, + current_mac_address: mac, + arch: BootArch::X86_64, + loader_version: "test-loader".into(), + }, + "http://10.77.0.1:2999".into(), + ) + .await + .unwrap(); + state + .loader_registry + .accept_poll(&LoaderPollRequest { + protocol_version: PROTOCOL_VERSION, + registration_id: offer.registration_id, + mac_address: mac, + current_mac_address: mac, + ip_address: "10.77.0.100".into(), + arch: BootArch::X86_64, + loader_version: "test-loader".into(), + hardware: LoaderHardwareInfo::default(), + }) + .await + .unwrap(); + assert_eq!(create_board(&app, &board).await, StatusCode::CREATED); + state.virtual_boards.shutdown().await; + } + + #[test] + fn qemu_binding_accepts_only_online_non_conflicting_loader() { + let mac = "02:00:00:00:00:42".parse().unwrap(); + let mut loader = crate::loader::LoaderDeviceSnapshot { + mac_address: mac, + current_mac_address: mac, + ip_address: "10.77.0.2".into(), + arch: BootArch::X86_64, + loader_version: "test-loader".into(), + hardware: LoaderHardwareInfo::default(), + last_seen_at: chrono::Utc::now(), + online: true, + conflict: false, + current_registration_id: Some("registration-1".into()), + }; + + assert!(virtual_loader_is_bindable(&loader, mac)); + loader.online = false; + assert!(!virtual_loader_is_bindable(&loader, mac)); + loader.online = true; + loader.conflict = true; + assert!(!virtual_loader_is_bindable(&loader, mac)); } #[tokio::test] @@ -3610,6 +4257,7 @@ mod tests { .await .unwrap(); assert_eq!(download_kernel.status(), StatusCode::OK); + assert_eq!(download_kernel.headers()[header::CONTENT_LENGTH], "12"); let kernel_body = to_bytes(download_kernel.into_body(), usize::MAX) .await .unwrap(); @@ -3701,6 +4349,148 @@ mod tests { assert!(ready_value["kernel_sha256"].as_str().unwrap().len() == 64); } + #[tokio::test] + async fn network_loader_reuses_session_boot_after_a_new_registration() { + let (app, state) = test_router_and_state_with_config(|_| {}).await; + let board = sample_httpboot_board("httpboot-network-1"); + 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 session_id = create_session(&app, &board.board_type).await; + + let upload_response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/api/v1/sessions/{session_id}/http-boot/kernel")) + .header("X-HttpBoot-Remote-Name", "kernel.elf") + .header("X-HttpBoot-Arch", "x86_64") + .header("X-HttpBoot-Image-Format", "elf64") + .header("X-HttpBoot-Entry-Symbol", "httpboot_entry") + .body(Body::from(vec![0x5a; 4096])) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(upload_response.status(), StatusCode::CREATED); + let body = to_bytes(upload_response.into_body(), usize::MAX) + .await + .unwrap(); + let published: httpboot_protocol::KernelPublishResponse = + serde_json::from_slice(&body).unwrap(); + + let probe = LoaderDiscoveryProbe { + protocol_version: PROTOCOL_VERSION, + mac_address: mac, + current_mac_address: mac, + arch: BootArch::X86_64, + loader_version: "test-loader".into(), + }; + let first = state + .loader_registry + .offer(&probe, "http://127.0.0.1:2999".into()) + .await + .unwrap(); + let make_poll = |registration_id: String| LoaderPollRequest { + protocol_version: PROTOCOL_VERSION, + registration_id, + mac_address: mac, + current_mac_address: mac, + ip_address: "10.77.0.2".into(), + arch: BootArch::X86_64, + loader_version: "test-loader".into(), + hardware: LoaderHardwareInfo { + manufacturer: Some("QEMU".into()), + product: Some("Standard PC".into()), + version: None, + serial: None, + }, + }; + + let first_poll = make_poll(first.registration_id.clone()); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/loaders/poll") + .body(Body::from(serde_json::to_vec(&first_poll).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let first_boot: LoaderPollResponse = serde_json::from_slice(&body).unwrap(); + let LoaderPollResponse::Boot { boot_id, .. } = first_boot else { + panic!("expected boot response"); + }; + assert_eq!(boot_id, published.boot_id); + + let status = LoaderStatusReport { + protocol_version: PROTOCOL_VERSION, + registration_id: first.registration_id.clone(), + mac_address: mac, + session_id: session_id.clone(), + boot_id: published.boot_id.clone(), + status: LoaderStatusPhase::ReadyToHandoff, + }; + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/loaders/status") + .body(Body::from(serde_json::to_vec(&status).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let second = state + .loader_registry + .offer(&probe, "http://127.0.0.1:2999".into()) + .await + .unwrap(); + let second_poll = make_poll(second.registration_id); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/loaders/poll") + .body(Body::from(serde_json::to_vec(&second_poll).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let second_boot: LoaderPollResponse = serde_json::from_slice(&body).unwrap(); + let LoaderPollResponse::Boot { boot_id, .. } = second_boot else { + panic!("expected boot response after restart"); + }; + assert_eq!(boot_id, published.boot_id); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/loaders/poll") + .body(Body::from(serde_json::to_vec(&first_poll).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let stale: LoaderPollResponse = serde_json::from_slice(&body).unwrap(); + assert!( + matches!(stale, LoaderPollResponse::Reject { ref code, .. } if code == "duplicate_mac") + ); + } + #[tokio::test] async fn httpboot_kernel_upload_accepts_other_arch() { let app = test_router().await; @@ -4058,6 +4848,7 @@ mod tests { id: Some("demo".into()), board_type: "demo".into(), tags: vec![], + network_identity: None, notes: None, disabled: false, serial: None, diff --git a/ostool-server/src/board_store/fs.rs b/ostool-server/src/board_store/fs.rs index 0df7ba30..19262c62 100644 --- a/ostool-server/src/board_store/fs.rs +++ b/ostool-server/src/board_store/fs.rs @@ -1,4 +1,8 @@ -use std::{collections::BTreeMap, ffi::OsStr, path::PathBuf}; +use std::{ + collections::{BTreeMap, BTreeSet}, + ffi::OsStr, + path::PathBuf, +}; use anyhow::{Context, bail}; use tokio::fs; @@ -26,6 +30,7 @@ impl FileBoardStore { pub async fn load_all(&self) -> anyhow::Result> { let mut boards = BTreeMap::new(); + let mut network_identities = BTreeSet::new(); let mut dir = fs::read_dir(&self.board_dir).await?; while let Some(entry) = dir.next_entry().await? { @@ -58,6 +63,16 @@ impl FileBoardStore { ); } + if let Some(identity) = board.network_identity.as_ref() + && !network_identities.insert(identity.mac_address) + { + bail!( + "duplicate network identity MAC `{}` in {}", + identity.mac_address, + path.display() + ); + } + if boards.insert(board.id.clone(), board).is_some() { bail!("duplicate board id `{stem}` in {}", path.display()); } @@ -109,6 +124,7 @@ mod tests { power_off_cmd: "echo off".into(), }), boot: BootConfig::Pxe(PxeProfile::default()), + network_identity: None, notes: None, disabled: false, }; diff --git a/ostool-server/src/config.rs b/ostool-server/src/config.rs index e98129e9..28be25a6 100644 --- a/ostool-server/src/config.rs +++ b/ostool-server/src/config.rs @@ -5,6 +5,7 @@ use std::{ }; use anyhow::{Context, bail}; +use httpboot_protocol::MacAddress; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tokio::fs; @@ -22,6 +23,10 @@ pub struct ServerConfig { pub tftp: TftpConfig, #[serde(default)] pub http_boot: HttpBootConfig, + #[serde(default)] + pub loader_network: LoaderNetworkConfig, + #[serde(default)] + pub virtual_qemu: VirtualQemuConfig, pub network: TftpNetworkConfig, #[serde(default)] pub upload_limits: UploadLimitsConfig, @@ -45,6 +50,7 @@ impl ServerConfig { let board_dir = data_dir.join("boards"); let dtb_dir = data_dir.join("dtbs"); let http_boot = HttpBootConfig::default_with_root(data_dir.join("http-boot")); + let virtual_qemu = VirtualQemuConfig::default_with_runtime_dir(data_dir.join("qemu")); #[cfg(target_os = "linux")] let tftp = TftpConfig::SystemTftpdHpa(SystemTftpdHpaConfig::default()); @@ -61,6 +67,8 @@ impl ServerConfig { dtb_dir, tftp, http_boot, + loader_network: LoaderNetworkConfig::default(), + virtual_qemu, network: TftpNetworkConfig::default(), upload_limits: UploadLimitsConfig::default(), } @@ -153,6 +161,14 @@ impl ServerConfig { self.board_dir = absolutize_path(&config_dir, &self.board_dir); self.dtb_dir = absolutize_path(&config_dir, &self.dtb_dir); self.http_boot.root_dir = absolutize_path(&config_dir, &self.http_boot.root_dir); + self.virtual_qemu.runtime_dir = + absolutize_path(&config_dir, &self.virtual_qemu.runtime_dir); + self.virtual_qemu.qemu_binary = + absolutize_path(&config_dir, &self.virtual_qemu.qemu_binary); + self.virtual_qemu.ovmf_code = absolutize_path(&config_dir, &self.virtual_qemu.ovmf_code); + self.virtual_qemu.ovmf_vars = absolutize_path(&config_dir, &self.virtual_qemu.ovmf_vars); + self.virtual_qemu.axloader_efi = + absolutize_path(&config_dir, &self.virtual_qemu.axloader_efi); match &mut self.tftp { TftpConfig::Builtin(cfg) => { @@ -176,6 +192,20 @@ impl ServerConfig { if self.upload_limits.session_file_max_mib == 0 { bail!("upload_limits.session_file_max_mib must be greater than 0"); } + if self.loader_network.bind_addr.port() != httpboot_protocol::DISCOVERY_PORT { + bail!( + "loader_network.bind_addr must use UDP port {}", + httpboot_protocol::DISCOVERY_PORT + ); + } + if let Some(public_base_url) = self.loader_network.public_base_url.as_deref() { + let url = url::Url::parse(public_base_url) + .context("loader_network.public_base_url must be an absolute URL")?; + if url.scheme() != "http" || url.cannot_be_a_base() { + bail!("loader_network.public_base_url must be an HTTP base URL"); + } + } + self.virtual_qemu.validate()?; Ok(()) } } @@ -187,6 +217,87 @@ pub struct HttpBootConfig { pub public_base_url: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct LoaderNetworkConfig { + pub enabled: bool, + pub bind_addr: SocketAddr, + pub public_base_url: Option, +} + +impl Default for LoaderNetworkConfig { + fn default() -> Self { + Self { + enabled: true, + bind_addr: SocketAddr::from(([0, 0, 0, 0], httpboot_protocol::DISCOVERY_PORT)), + public_base_url: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct VirtualQemuConfig { + #[serde(default)] + pub enabled: bool, + pub qemu_binary: PathBuf, + pub ovmf_code: PathBuf, + pub ovmf_vars: PathBuf, + pub axloader_efi: PathBuf, + pub runtime_dir: PathBuf, + pub network_namespace: String, + pub bridge: String, + #[serde(default)] + pub tap_pool: Vec, + pub memory_mib: u32, + pub cpus: u16, +} + +impl Default for VirtualQemuConfig { + fn default() -> Self { + Self::default_with_runtime_dir(PathBuf::from(".ostool-server/qemu")) + } +} + +impl VirtualQemuConfig { + pub fn default_with_runtime_dir(runtime_dir: PathBuf) -> Self { + Self { + enabled: false, + qemu_binary: PathBuf::from("/usr/local/bin/qemu-system-x86_64"), + ovmf_code: PathBuf::from("/usr/share/OVMF/OVMF_CODE_4M.fd"), + ovmf_vars: PathBuf::from("/usr/share/OVMF/OVMF_VARS_4M.fd"), + axloader_efi: PathBuf::from("axloader.efi"), + runtime_dir, + network_namespace: "ostool-qemu".into(), + bridge: "ostool-br0".into(), + tap_pool: vec!["ostool-tap0".into()], + memory_mib: 512, + cpus: 2, + } + } + + fn validate(&self) -> anyhow::Result<()> { + if !self.enabled { + return Ok(()); + } + if self.network_namespace.trim().is_empty() || self.bridge.trim().is_empty() { + bail!("virtual_qemu.network_namespace and bridge must not be empty"); + } + if self.tap_pool.is_empty() || self.tap_pool.iter().any(|name| name.trim().is_empty()) { + bail!("virtual_qemu.tap_pool must contain at least one non-empty TAP name"); + } + let unique_taps = self + .tap_pool + .iter() + .collect::>(); + if unique_taps.len() != self.tap_pool.len() { + bail!("virtual_qemu.tap_pool must not contain duplicate TAP names"); + } + if self.memory_mib == 0 || self.cpus == 0 { + bail!("virtual_qemu memory_mib and cpus must be greater than 0"); + } + Ok(()) + } +} + impl HttpBootConfig { pub fn default_with_root(root_dir: PathBuf) -> Self { Self { @@ -375,6 +486,8 @@ pub struct BoardConfig { pub serial: Option, pub power_management: PowerManagementConfig, pub boot: BootConfig, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub network_identity: Option, pub notes: Option, #[serde(default)] pub disabled: bool, @@ -385,10 +498,44 @@ impl BoardConfig { if let BootConfig::Uboot(profile) = &self.boot { profile.validate()?; } + if matches!(self.boot, BootConfig::UefiHttp(_)) && self.network_identity.is_none() { + anyhow::bail!("network_identity.mac_address is required for httpboot boards"); + } + if let PowerManagementConfig::Qemu { virtual_device_id } = &self.power_management { + if !matches!(self.boot, BootConfig::UefiHttp(_)) { + anyhow::bail!("QEMU boards must use httpboot"); + } + if virtual_device_id.trim().is_empty() { + anyhow::bail!("QEMU virtual_device_id must not be empty"); + } + let serial = self + .serial + .as_ref() + .context("QEMU boards must configure a QEMU serial key")?; + if serial.key.kind != SerialPortKeyKind::Qemu || serial.key.value != *virtual_device_id + { + anyhow::bail!("QEMU power and serial must reference the same virtual_device_id"); + } + if self.network_identity.is_none() { + anyhow::bail!("QEMU boards must configure network_identity.mac_address"); + } + } else if self + .serial + .as_ref() + .is_some_and(|serial| serial.key.kind == SerialPortKeyKind::Qemu) + { + anyhow::bail!("QEMU serial keys require QEMU power management"); + } Ok(()) } } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct BoardNetworkIdentity { + #[schemars(with = "String")] + pub mac_address: MacAddress, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct SerialConfig { pub key: SerialPortKey, @@ -410,6 +557,7 @@ pub struct SerialPortKey { pub enum SerialPortKeyKind { SerialNumber, UsbPath, + Qemu, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -417,6 +565,7 @@ pub enum SerialPortKeyKind { pub enum PowerManagementConfig { Custom(CustomPowerManagement), ZhongshengRelay(ZhongshengRelayPowerManagement), + Qemu { virtual_device_id: String }, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -529,9 +678,6 @@ pub enum UefiBootArch { pub struct UefiHttpProfile { #[serde(default)] pub boot_arch: Option, - #[serde(default, skip_serializing)] - #[schemars(skip)] - pub mac: Option, } #[cfg(test)] @@ -545,9 +691,9 @@ mod tests { use tempfile::tempdir; use super::{ - BoardConfig, BootConfig, CustomPowerManagement, PowerManagementConfig, SerialPortKey, - SerialPortKeyKind, ServerConfig, UbootNetworkMode, UbootProfile, UefiBootArch, - UefiHttpProfile, ZhongshengRelayPowerManagement, + BoardConfig, BoardNetworkIdentity, BootConfig, CustomPowerManagement, + PowerManagementConfig, SerialPortKey, SerialPortKeyKind, ServerConfig, UbootNetworkMode, + UbootProfile, UefiBootArch, UefiHttpProfile, ZhongshengRelayPowerManagement, }; #[test] @@ -799,6 +945,7 @@ network_mode = "static_ip" dtb_name: Some("board.dtb".into()), ..Default::default() }), + network_identity: None, notes: None, disabled: false, }; @@ -854,7 +1001,9 @@ bootm_addr = "0x82200000" }), boot: BootConfig::UefiHttp(UefiHttpProfile { boot_arch: Some(UefiBootArch::X86_64), - mac: None, + }), + network_identity: Some(BoardNetworkIdentity { + mac_address: "02:00:00:00:00:01".parse().unwrap(), }), notes: None, disabled: false, @@ -862,14 +1011,93 @@ bootm_addr = "0x82200000" let encoded = toml::to_string_pretty(&board).unwrap(); assert!(encoded.contains("kind = \"httpboot\"")); - assert!(!encoded.contains("mac = ")); + assert!(encoded.contains("mac_address = \"02:00:00:00:00:01\"")); let decoded: BoardConfig = toml::from_str(&encoded).unwrap(); let BootConfig::UefiHttp(profile) = decoded.boot else { panic!("expected httpboot"); }; assert_eq!(profile.boot_arch, Some(UefiBootArch::X86_64)); - assert_eq!(profile.mac, None); + assert_eq!( + decoded.network_identity.unwrap().mac_address.to_string(), + "02:00:00:00:00:01" + ); + } + + #[test] + fn httpboot_board_requires_persistent_network_identity() { + let mut board = BoardConfig { + id: "uefi-http-01".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: None, + notes: None, + disabled: false, + }; + + assert!( + board + .validate() + .unwrap_err() + .to_string() + .contains("network_identity.mac_address is required for httpboot boards") + ); + board.network_identity = Some(BoardNetworkIdentity { + mac_address: "02:00:00:00:00:01".parse().unwrap(), + }); + board.validate().unwrap(); + } + + #[test] + fn qemu_board_requires_httpboot_and_matching_virtual_serial() { + let mut board = BoardConfig { + id: "qemu-01".into(), + board_type: "qemu-x86_64".into(), + tags: vec![], + serial: Some(super::SerialConfig { + key: SerialPortKey { + kind: SerialPortKeyKind::Qemu, + value: "virtual-1".into(), + }, + baud_rate: 115_200, + resolved_device_path: None, + resolved_usb_path: None, + }), + power_management: PowerManagementConfig::Qemu { + virtual_device_id: "virtual-1".into(), + }, + boot: BootConfig::Pxe(Default::default()), + network_identity: Some(BoardNetworkIdentity { + mac_address: "02:00:00:00:00:01".parse().unwrap(), + }), + notes: None, + disabled: false, + }; + + assert_eq!( + board.validate().unwrap_err().to_string(), + "QEMU boards must use httpboot" + ); + board.boot = BootConfig::UefiHttp(UefiHttpProfile { + boot_arch: Some(UefiBootArch::X86_64), + }); + board.validate().unwrap(); + board.serial.as_mut().unwrap().key.value = "virtual-2".into(); + assert!( + board + .validate() + .unwrap_err() + .to_string() + .contains("QEMU power and serial must reference the same virtual_device_id") + ); } #[test] @@ -895,7 +1123,10 @@ mac = "1c:69:7a:dc:f3:47" ) .unwrap_err(); - assert!(error.to_string().contains("unknown field `strategy`")); + assert!( + error.to_string().contains("unknown field `strategy`") + || error.to_string().contains("unknown field `mac`") + ); } #[test] @@ -914,6 +1145,7 @@ mac = "1c:69:7a:dc:f3:47" }, ), boot: BootConfig::Pxe(Default::default()), + network_identity: None, notes: None, disabled: false, }; diff --git a/ostool-server/src/lib.rs b/ostool-server/src/lib.rs index 9f84dc93..e745e0c3 100644 --- a/ostool-server/src/lib.rs +++ b/ostool-server/src/lib.rs @@ -6,20 +6,24 @@ pub mod board_store; pub mod config; pub mod dtb_store; pub mod http_boot; +pub mod loader; pub mod power; pub mod process; pub mod serial; pub mod session; pub mod state; pub mod tftp; +pub mod virtual_lab; +pub mod virtual_qemu; pub mod web; pub use api::router::build_router; pub use config::{ - BoardConfig, BootConfig, BuiltinTftpConfig, CustomPowerManagement, PowerManagementConfig, - PxeProfile, SerialConfig, SerialPortKey, SerialPortKeyKind, ServerConfig, SystemTftpdHpaConfig, - TftpConfig, TftpNetworkConfig, UbootNetworkMode, UbootProfile, UefiBootArch, UefiHttpProfile, - UploadLimitsConfig, ZhongshengRelayPowerManagement, + BoardConfig, BoardNetworkIdentity, BootConfig, BuiltinTftpConfig, CustomPowerManagement, + LoaderNetworkConfig, PowerManagementConfig, PxeProfile, SerialConfig, SerialPortKey, + SerialPortKeyKind, ServerConfig, SystemTftpdHpaConfig, TftpConfig, TftpNetworkConfig, + UbootNetworkMode, UbootProfile, UefiBootArch, UefiHttpProfile, UploadLimitsConfig, + VirtualQemuConfig, ZhongshengRelayPowerManagement, }; pub use dtb_store::{DtbFile, DtbStore}; pub use state::{AppState, BoardLeaseState, build_app_state}; diff --git a/ostool-server/src/loader.rs b/ostool-server/src/loader.rs new file mode 100644 index 00000000..8bae4e90 --- /dev/null +++ b/ostool-server/src/loader.rs @@ -0,0 +1,496 @@ +use std::{collections::BTreeMap, sync::Arc}; + +use anyhow::Context; +use chrono::{DateTime, Duration, Utc}; +use httpboot_protocol::{ + LoaderDiscoveryOffer, LoaderDiscoveryProbe, LoaderHardwareInfo, LoaderPollRequest, + LoaderStatusReport, MAX_DISCOVERY_DATAGRAM_BYTES, MacAddress, PROTOCOL_VERSION, +}; +use serde::Serialize; +use tokio::{net::UdpSocket, sync::Mutex, task::JoinHandle}; + +use crate::state::AppState; + +const REGISTRATION_TTL: Duration = Duration::seconds(30); +const ONLINE_TTL: Duration = Duration::seconds(10); +const RECORD_RETENTION: Duration = Duration::hours(24); + +#[derive(Debug, Clone, Serialize)] +pub struct LoaderDeviceSnapshot { + pub mac_address: MacAddress, + pub current_mac_address: MacAddress, + pub ip_address: String, + pub arch: httpboot_protocol::BootArch, + pub loader_version: String, + pub hardware: LoaderHardwareInfo, + pub last_seen_at: DateTime, + pub online: bool, + pub conflict: bool, + pub current_registration_id: Option, +} + +#[derive(Debug, Clone)] +struct Registration { + mac_address: MacAddress, + issued_at: DateTime, + last_seen_at: Option>, + superseded_at: Option>, +} + +#[derive(Debug, Clone)] +struct DeviceRecord { + current_mac_address: MacAddress, + ip_address: String, + arch: httpboot_protocol::BootArch, + loader_version: String, + hardware: LoaderHardwareInfo, + last_seen_at: DateTime, + current_registration_id: String, + conflict: bool, +} + +#[derive(Debug, Default)] +struct LoaderRegistryState { + registrations: BTreeMap, + devices: BTreeMap, +} + +#[derive(Debug, Clone)] +pub struct LoaderRegistry { + server_id: Arc, + state: Arc>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegistrationError { + ProtocolVersion, + Unknown, + Expired, + MacMismatch, + Replaced, + Conflict, +} + +impl LoaderRegistry { + pub fn new() -> Self { + Self { + server_id: uuid::Uuid::new_v4().to_string().into(), + state: Arc::new(Mutex::new(LoaderRegistryState::default())), + } + } + + pub async fn offer( + &self, + probe: &LoaderDiscoveryProbe, + control_base_url: String, + ) -> Result { + if probe.protocol_version != PROTOCOL_VERSION { + return Err(RegistrationError::ProtocolVersion); + } + let now = Utc::now(); + let registration_id = uuid::Uuid::new_v4().to_string(); + let mut state = self.state.lock().await; + prune(&mut state, now); + state.registrations.insert( + registration_id.clone(), + Registration { + mac_address: probe.mac_address, + issued_at: now, + last_seen_at: None, + superseded_at: None, + }, + ); + Ok(LoaderDiscoveryOffer { + protocol_version: PROTOCOL_VERSION, + server_id: self.server_id.to_string(), + control_base_url, + registration_id, + expires_in_ms: REGISTRATION_TTL.num_milliseconds() as u64, + }) + } + + pub async fn accept_poll( + &self, + request: &LoaderPollRequest, + ) -> Result { + if request.protocol_version != PROTOCOL_VERSION { + return Err(RegistrationError::ProtocolVersion); + } + let now = Utc::now(); + let mut state = self.state.lock().await; + prune(&mut state, now); + + let registration = state + .registrations + .get(&request.registration_id) + .ok_or(RegistrationError::Unknown)?; + if registration.mac_address != request.mac_address { + return Err(RegistrationError::MacMismatch); + } + if registration.last_seen_at.is_none() && now - registration.issued_at > REGISTRATION_TTL { + return Err(RegistrationError::Expired); + } + let issued_at = registration.issued_at; + let was_superseded = registration.superseded_at.is_some(); + state + .registrations + .get_mut(&request.registration_id) + .expect("registration was validated above") + .last_seen_at = Some(now); + + if was_superseded { + if let Some(device) = state.devices.get_mut(&request.mac_address) { + device.conflict = true; + } + return Ok(true); + } + + if let Some(previous_id) = state + .devices + .get(&request.mac_address) + .map(|device| device.current_registration_id.clone()) + .filter(|previous_id| previous_id != &request.registration_id) + { + let previous_issued_at = state + .registrations + .get(&previous_id) + .map(|registration| registration.issued_at) + .unwrap_or(DateTime::::MIN_UTC); + if issued_at > previous_issued_at { + if let Some(previous) = state.registrations.get_mut(&previous_id) { + previous.superseded_at = Some(now); + } + } else { + state + .registrations + .get_mut(&request.registration_id) + .expect("registration was validated above") + .superseded_at = Some(now); + if let Some(device) = state.devices.get_mut(&request.mac_address) { + device.conflict = true; + } + return Ok(true); + } + } + + let conflict = has_live_superseded_report(&state, request.mac_address, now); + + state.devices.insert( + request.mac_address, + DeviceRecord { + current_mac_address: request.current_mac_address, + ip_address: request.ip_address.clone(), + arch: request.arch, + loader_version: request.loader_version.clone(), + hardware: request.hardware.clone(), + last_seen_at: now, + current_registration_id: request.registration_id.clone(), + conflict, + }, + ); + Ok(conflict) + } + + pub async fn accept_status( + &self, + report: &LoaderStatusReport, + ) -> Result<(), RegistrationError> { + if report.protocol_version != PROTOCOL_VERSION { + return Err(RegistrationError::ProtocolVersion); + } + let now = Utc::now(); + let mut state = self.state.lock().await; + prune(&mut state, now); + let registration = state + .registrations + .get(&report.registration_id) + .ok_or(RegistrationError::Unknown)?; + if registration.mac_address != report.mac_address { + return Err(RegistrationError::MacMismatch); + } + if registration.superseded_at.is_some() { + state + .registrations + .get_mut(&report.registration_id) + .expect("registration was validated above") + .last_seen_at = Some(now); + if let Some(device) = state.devices.get_mut(&report.mac_address) { + device.conflict = true; + } + return Err(RegistrationError::Replaced); + } + let (conflict, current_registration_id) = state + .devices + .get(&report.mac_address) + .map(|device| (device.conflict, device.current_registration_id.clone())) + .ok_or(RegistrationError::Unknown)?; + if conflict { + return Err(RegistrationError::Conflict); + } + if current_registration_id != report.registration_id { + return Err(RegistrationError::Replaced); + } + state + .registrations + .get_mut(&report.registration_id) + .expect("registration was validated above") + .last_seen_at = Some(now); + state + .devices + .get_mut(&report.mac_address) + .expect("device was validated above") + .last_seen_at = now; + Ok(()) + } + + pub async fn snapshots(&self) -> Vec { + let now = Utc::now(); + let mut state = self.state.lock().await; + prune(&mut state, now); + state + .devices + .iter() + .map(|(mac_address, record)| LoaderDeviceSnapshot { + mac_address: *mac_address, + current_mac_address: record.current_mac_address, + ip_address: record.ip_address.clone(), + arch: record.arch, + loader_version: record.loader_version.clone(), + hardware: record.hardware.clone(), + last_seen_at: record.last_seen_at, + online: now - record.last_seen_at <= ONLINE_TTL, + conflict: record.conflict, + current_registration_id: Some(record.current_registration_id.clone()), + }) + .collect() + } +} + +impl Default for LoaderRegistry { + fn default() -> Self { + Self::new() + } +} + +fn prune(state: &mut LoaderRegistryState, now: DateTime) { + state.registrations.retain(|_, registration| { + registration.last_seen_at.map_or( + now - registration.issued_at <= REGISTRATION_TTL, + |last_seen| now - last_seen <= RECORD_RETENTION, + ) + }); + state + .devices + .retain(|_, device| now - device.last_seen_at <= RECORD_RETENTION); + + let conflicts = state + .devices + .keys() + .copied() + .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); + } +} + +fn has_live_superseded_report( + state: &LoaderRegistryState, + mac: MacAddress, + now: DateTime, +) -> bool { + state.registrations.values().any(|registration| { + registration.mac_address == mac + && registration.superseded_at.is_some_and(|superseded_at| { + registration.last_seen_at.is_some_and(|last_seen| { + last_seen > superseded_at && now - last_seen <= ONLINE_TTL + }) + }) + }) +} + +pub async fn start_udp_discovery(state: AppState) -> anyhow::Result>> { + let server_config = state.config.read().await.clone(); + if !server_config.loader_network.enabled { + return Ok(None); + } + let socket = UdpSocket::bind(server_config.loader_network.bind_addr) + .await + .with_context(|| { + format!( + "failed to bind loader discovery UDP {}", + server_config.loader_network.bind_addr + ) + })?; + socket.set_broadcast(true)?; + Ok(Some(tokio::spawn(async move { + if let Err(error) = serve_udp_discovery(state, server_config, socket).await { + log::error!("loader discovery stopped: {error:#}"); + } + }))) +} + +async fn serve_udp_discovery( + state: AppState, + server_config: crate::config::ServerConfig, + socket: UdpSocket, +) -> anyhow::Result<()> { + let mut buffer = [0_u8; MAX_DISCOVERY_DATAGRAM_BYTES + 1]; + loop { + let (length, peer) = socket.recv_from(&mut buffer).await?; + if length > MAX_DISCOVERY_DATAGRAM_BYTES { + continue; + } + let Ok(probe) = serde_json::from_slice::(&buffer[..length]) else { + continue; + }; + let base_url = advertised_base_url(&server_config)?; + let Ok(offer) = state.loader_registry.offer(&probe, base_url).await else { + continue; + }; + let response = serde_json::to_vec(&offer)?; + if response.len() <= MAX_DISCOVERY_DATAGRAM_BYTES { + socket.send_to(&response, peer).await?; + } + } +} + +fn advertised_base_url(config: &crate::config::ServerConfig) -> anyhow::Result { + if let Some(public_base_url) = config.loader_network.public_base_url.as_deref() { + return Ok(public_base_url.trim_end_matches('/').to_string()); + } + crate::api::router::http_boot_public_base_url(config) + .map(|url| url.as_str().trim_end_matches('/').to_string()) + .map_err(|error| anyhow::anyhow!(error.message)) +} + +#[cfg(test)] +mod tests { + use super::*; + use httpboot_protocol::{BootArch, LoaderStatusPhase, PROTOCOL_VERSION}; + + fn probe(mac: MacAddress) -> LoaderDiscoveryProbe { + LoaderDiscoveryProbe { + protocol_version: PROTOCOL_VERSION, + mac_address: mac, + current_mac_address: mac, + arch: BootArch::X86_64, + loader_version: "test".into(), + } + } + + fn poll(mac: MacAddress, registration_id: String) -> LoaderPollRequest { + LoaderPollRequest { + protocol_version: PROTOCOL_VERSION, + registration_id, + mac_address: mac, + current_mac_address: mac, + ip_address: "10.77.0.2".into(), + arch: BootArch::X86_64, + loader_version: "test".into(), + hardware: LoaderHardwareInfo::default(), + } + } + + #[tokio::test] + async fn a_new_generation_replaces_the_previous_one_until_it_reports_again() { + let registry = LoaderRegistry::new(); + let mac = "02:00:00:00:00:01".parse().unwrap(); + let first = registry + .offer(&probe(mac), "http://host".into()) + .await + .unwrap(); + assert!( + !registry + .accept_poll(&poll(mac, first.registration_id.clone())) + .await + .unwrap() + ); + let second = registry + .offer(&probe(mac), "http://host".into()) + .await + .unwrap(); + assert!( + !registry + .accept_poll(&poll(mac, second.registration_id.clone())) + .await + .unwrap() + ); + assert!(!registry.snapshots().await[0].conflict); + assert!( + registry + .accept_poll(&poll(mac, first.registration_id)) + .await + .unwrap() + ); + assert!(registry.snapshots().await[0].conflict); + } + + #[tokio::test] + async fn registration_cannot_claim_another_mac() { + let registry = LoaderRegistry::new(); + let first_mac = "02:00:00:00:00:01".parse().unwrap(); + let second_mac = "02:00:00:00:00:02".parse().unwrap(); + let offer = registry + .offer(&probe(first_mac), "http://host".into()) + .await + .unwrap(); + assert_eq!( + registry + .accept_poll(&poll(second_mac, offer.registration_id)) + .await, + Err(RegistrationError::MacMismatch) + ); + } + + #[tokio::test] + async fn rejects_an_incompatible_protocol_version() { + let registry = LoaderRegistry::new(); + let mac = "02:00:00:00:00:01".parse().unwrap(); + let mut incompatible = probe(mac); + incompatible.protocol_version = PROTOCOL_VERSION + 1; + assert_eq!( + registry.offer(&incompatible, "http://host".into()).await, + Err(RegistrationError::ProtocolVersion) + ); + } + + #[tokio::test] + async fn status_reports_refresh_the_current_device_heartbeat() { + let registry = LoaderRegistry::new(); + let mac = "02:00:00:00:00:01".parse().unwrap(); + let offer = registry + .offer(&probe(mac), "http://host".into()) + .await + .unwrap(); + registry + .accept_poll(&poll(mac, offer.registration_id.clone())) + .await + .unwrap(); + registry + .state + .lock() + .await + .devices + .get_mut(&mac) + .unwrap() + .last_seen_at = Utc::now() - ONLINE_TTL - Duration::seconds(1); + + registry + .accept_status(&LoaderStatusReport { + protocol_version: PROTOCOL_VERSION, + registration_id: offer.registration_id, + mac_address: mac, + session_id: "session".into(), + boot_id: "boot".into(), + status: LoaderStatusPhase::Downloading { + received: 1, + total: 2, + }, + }) + .await + .unwrap(); + + assert!(registry.snapshots().await[0].online); + } +} diff --git a/ostool-server/src/main.rs b/ostool-server/src/main.rs index f4a8bd0e..33866e47 100644 --- a/ostool-server/src/main.rs +++ b/ostool-server/src/main.rs @@ -1,11 +1,13 @@ use std::{path::PathBuf, sync::Arc, time::Duration}; use anyhow::Context; -use clap::Parser; +use clap::{Parser, Subcommand}; use log::info; use ostool_server::{ ServerConfig, build_app_state, build_router, + loader::start_udp_discovery, tftp::service::{BuiltinTftpManager, SystemTftpdHpaManager, TftpManager}, + virtual_lab::{VirtualLabAction, run_virtual_lab}, }; #[derive(Parser, Debug)] @@ -13,6 +15,24 @@ use ostool_server::{ struct Cli { #[arg(short, long, default_value = ".ostool-server.toml")] config: PathBuf, + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Manage the isolated QEMU network namespace and TAP pool. + VirtualLab { + #[command(subcommand)] + action: VirtualLabCommand, + }, +} + +#[derive(Subcommand, Debug)] +enum VirtualLabCommand { + Up, + Status, + Down, } #[tokio::main] @@ -21,6 +41,14 @@ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); let config = ServerConfig::load_or_create(&cli.config).await?; + if let Some(Command::VirtualLab { action }) = cli.command { + let action = match action { + VirtualLabCommand::Up => VirtualLabAction::Up, + VirtualLabCommand::Status => VirtualLabAction::Status, + VirtualLabCommand::Down => VirtualLabAction::Down, + }; + return run_virtual_lab(&config.virtual_qemu, action).await; + } let tftp_manager: Arc = match &config.tftp { ostool_server::TftpConfig::Builtin(cfg) => Arc::new(BuiltinTftpManager::new(cfg.clone())), ostool_server::TftpConfig::SystemTftpdHpa(cfg) => { @@ -35,6 +63,7 @@ async fn main() -> anyhow::Result<()> { "failed to power off board `{board_id}` during server startup; marking it disabled for this process: {err}" ); } + let discovery_task = start_udp_discovery(state.clone()).await?; tftp_manager.start_if_needed().await?; if let ostool_server::TftpConfig::SystemTftpdHpa(cfg) = &state.config.read().await.tftp && cfg.reconcile_on_start @@ -57,6 +86,42 @@ async fn main() -> anyhow::Result<()> { .await .with_context(|| format!("failed to bind {listen_addr}"))?; info!("ostoold listening on {listen_addr}"); - axum::serve(listener, app).await?; + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await?; + if let Some(task) = discovery_task { + task.abort(); + let _ = task.await; + } + state.virtual_boards.shutdown().await; Ok(()) } + +async fn shutdown_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + + let mut terminate = match signal(SignalKind::terminate()) { + Ok(signal) => signal, + Err(error) => { + log::warn!("failed to install SIGTERM handler: {error}"); + let _ = tokio::signal::ctrl_c().await; + return; + } + }; + tokio::select! { + result = tokio::signal::ctrl_c() => { + if let Err(error) = result { + log::warn!("failed to wait for Ctrl-C: {error}"); + } + } + _ = terminate.recv() => {} + } + } + + #[cfg(not(unix))] + if let Err(error) = tokio::signal::ctrl_c().await { + log::warn!("failed to wait for Ctrl-C: {error}"); + } +} diff --git a/ostool-server/src/power.rs b/ostool-server/src/power.rs index 62704bbd..c6457837 100644 --- a/ostool-server/src/power.rs +++ b/ostool-server/src/power.rs @@ -94,6 +94,9 @@ pub async fn execute_power_action( resolved_serial.current_device_path )) } + PowerManagementConfig::Qemu { .. } => Err(PowerActionError::InvalidConfig( + "QEMU power actions must be executed by AppState".into(), + )), } } @@ -114,6 +117,7 @@ fn relay_serial_key_kind_label(kind: &SerialPortKeyKind) -> &'static str { match kind { SerialPortKeyKind::SerialNumber => "serial number", SerialPortKeyKind::UsbPath => "usb path", + SerialPortKeyKind::Qemu => "qemu virtual device", } } @@ -179,6 +183,7 @@ mod tests { serial: None, power_management, boot: BootConfig::Pxe(PxeProfile::default()), + network_identity: None, notes: None, disabled: false, } diff --git a/ostool-server/src/serial/discovery.rs b/ostool-server/src/serial/discovery.rs index 7f3dffeb..9efe6edd 100644 --- a/ostool-server/src/serial/discovery.rs +++ b/ostool-server/src/serial/discovery.rs @@ -272,6 +272,7 @@ fn serial_key_kind_short_label(kind: &SerialPortKeyKind) -> &'static str { match kind { SerialPortKeyKind::SerialNumber => "SN", SerialPortKeyKind::UsbPath => "USB PATH", + SerialPortKeyKind::Qemu => "QEMU", } } @@ -279,6 +280,7 @@ fn serial_key_kind_label(kind: &SerialPortKeyKind) -> &'static str { match kind { SerialPortKeyKind::SerialNumber => "serial number", SerialPortKeyKind::UsbPath => "usb path", + SerialPortKeyKind::Qemu => "qemu virtual device", } } diff --git a/ostool-server/src/serial/ws.rs b/ostool-server/src/serial/ws.rs index 04940b64..fb2ffbc1 100644 --- a/ostool-server/src/serial/ws.rs +++ b/ostool-server/src/serial/ws.rs @@ -1,16 +1,20 @@ -use std::time::Duration; +use std::{ + pin::Pin, + task::{Context as TaskContext, Poll}, + time::Duration, +}; use anyhow::Context; use axum::extract::ws::{Message, WebSocket}; use base64::Engine; use futures_util::{Sink, SinkExt, StreamExt}; use serde::Deserialize; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; use tokio::task::JoinHandle; use tokio_serial::{ClearBuffer, SerialPort, SerialPortBuilderExt}; use crate::{ - config::BoardConfig, + config::{BoardConfig, SerialConfig, SerialPortKeyKind}, power::{PowerAction, PowerActionError}, serial::discovery::resolve_serial_config, session::SessionState, @@ -20,6 +24,54 @@ use crate::{ const SERIAL_READ_BUFFER_SIZE: usize = 64; const SERIAL_READ_TIMEOUT: Duration = Duration::from_millis(20); +enum BoardSerialStream { + Physical(tokio_serial::SerialStream), + Qemu(tokio::io::DuplexStream), +} + +impl AsyncRead for BoardSerialStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buffer: &mut ReadBuf<'_>, + ) -> Poll> { + match &mut *self { + Self::Physical(stream) => Pin::new(stream).poll_read(cx, buffer), + Self::Qemu(stream) => Pin::new(stream).poll_read(cx, buffer), + } + } +} + +impl AsyncWrite for BoardSerialStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + bytes: &[u8], + ) -> Poll> { + match &mut *self { + Self::Physical(stream) => Pin::new(stream).poll_write(cx, bytes), + Self::Qemu(stream) => Pin::new(stream).poll_write(cx, bytes), + } + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + match &mut *self { + Self::Physical(stream) => Pin::new(stream).poll_flush(cx), + Self::Qemu(stream) => Pin::new(stream).poll_flush(cx), + } + } + + fn poll_shutdown( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + ) -> Poll> { + match &mut *self { + Self::Physical(stream) => Pin::new(stream).poll_shutdown(cx), + Self::Qemu(stream) => Pin::new(stream).poll_shutdown(cx), + } + } +} + #[derive(Debug, Deserialize)] struct ClientControlMessage { #[serde(rename = "type")] @@ -51,16 +103,7 @@ async fn run_serial_ws_inner( .serial .as_ref() .ok_or_else(|| anyhow::anyhow!("board has no serial configuration"))?; - let resolved_serial = resolve_serial_config(serial)?; - let mut port = tokio_serial::new(&resolved_serial.current_device_path, serial.baud_rate) - .timeout(SERIAL_READ_TIMEOUT) - .open_native_async() - .with_context(|| { - format!( - "failed to open serial port {}", - resolved_serial.current_device_path - ) - })?; + let mut port = open_board_serial(state, serial).await?; clear_serial_input_after_open(&session_id, &mut port); let (mut ws_sender, mut ws_receiver) = socket.split(); @@ -202,6 +245,32 @@ async fn run_serial_ws_inner( result } +async fn open_board_serial( + state: &AppState, + serial: &SerialConfig, +) -> anyhow::Result { + if serial.key.kind == SerialPortKeyKind::Qemu { + return Ok(BoardSerialStream::Qemu( + state + .virtual_boards + .attach_serial(&serial.key.value) + .await?, + )); + } + + let resolved_serial = resolve_serial_config(serial)?; + let port = tokio_serial::new(&resolved_serial.current_device_path, serial.baud_rate) + .timeout(SERIAL_READ_TIMEOUT) + .open_native_async() + .with_context(|| { + format!( + "failed to open serial port {}", + resolved_serial.current_device_path + ) + })?; + Ok(BoardSerialStream::Physical(port)) +} + fn spawn_power_action_task( state: AppState, board: BoardConfig, @@ -270,6 +339,15 @@ impl SerialOpenCleanup for tokio_serial::SerialStream { } } +impl SerialOpenCleanup for BoardSerialStream { + fn clear_input_buffer(&mut self) -> std::io::Result<()> { + match self { + Self::Physical(stream) => stream.clear_input_buffer(), + Self::Qemu(_) => Ok(()), + } + } +} + fn clear_serial_input_after_open(session_id: &str, port: &mut T) where T: SerialOpenCleanup + ?Sized, @@ -279,10 +357,13 @@ where } } -async fn write_serial_payload( - port: &mut tokio::io::WriteHalf, +async fn write_serial_payload( + port: &mut tokio::io::WriteHalf, payload: &[u8], -) -> anyhow::Result<()> { +) -> anyhow::Result<()> +where + T: AsyncWrite + Unpin, +{ port.write_all(payload).await?; port.flush().await?; Ok(()) @@ -305,6 +386,20 @@ impl SerialQueueCleanup for tokio_serial::SerialStream { } } +#[async_trait::async_trait] +impl SerialQueueCleanup for BoardSerialStream { + async fn flush_output(&mut self) -> std::io::Result<()> { + AsyncWriteExt::flush(self).await + } + + fn clear_all_buffers(&mut self) -> std::io::Result<()> { + match self { + Self::Physical(stream) => stream.clear_all_buffers(), + Self::Qemu(_) => Ok(()), + } + } +} + async fn cleanup_serial_queue_before_close(port: &mut T) -> anyhow::Result<()> where T: SerialQueueCleanup + ?Sized, @@ -561,6 +656,7 @@ mod tests { power_off_cmd: format!("printf 'off\\n' >> {}", output_path.display()), }), boot: BootConfig::Pxe(PxeProfile::default()), + network_identity: None, notes: None, disabled: false, }; @@ -592,6 +688,7 @@ mod tests { power_off_cmd: format!("printf 'off\\n' >> {}", output_path.display()), }), boot: BootConfig::Pxe(PxeProfile::default()), + network_identity: None, notes: None, disabled: false, }; diff --git a/ostool-server/src/session.rs b/ostool-server/src/session.rs index e07287a9..466caa98 100644 --- a/ostool-server/src/session.rs +++ b/ostool-server/src/session.rs @@ -4,6 +4,7 @@ use std::sync::{ }; use chrono::{DateTime, Duration, Utc}; +use httpboot_protocol::{BootArch, ImageFormat, LoaderStatusPhase, LoaderStatusResponse}; use serde::{Deserialize, Serialize}; use tokio::sync::{RwLock, mpsc, watch}; @@ -102,9 +103,33 @@ pub struct SessionState { lifecycle_state: AtomicU8, stop_requested: AtomicBool, serial_connected: AtomicBool, + loader: RwLock, command_tx: Option>, } +#[derive(Debug, Default)] +struct SessionLoaderState { + boot_command: Option, + status: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionBootCommand { + pub boot_id: String, + pub kernel_path: String, + pub kernel_size: u64, + pub kernel_sha256: String, + pub arch: BootArch, + pub image_format: ImageFormat, + pub entry_symbol: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoaderStatusUpdateError { + NoBootCommand, + StaleBoot, +} + impl SessionState { pub fn new(board: BoardConfig, client_name: Option) -> Arc { Self::new_inner(uuid::Uuid::new_v4().to_string(), board, client_name, None) @@ -140,6 +165,7 @@ impl SessionState { lifecycle_state: AtomicU8::new(SessionLifecycleState::Active.as_u8()), stop_requested: AtomicBool::new(false), serial_connected: AtomicBool::new(false), + loader: RwLock::new(SessionLoaderState::default()), command_tx, }) } @@ -229,6 +255,56 @@ impl SessionState { pub fn is_serial_connected(&self) -> bool { self.serial_connected.load(Ordering::Acquire) } + + pub async fn publish_boot_command(&self, command: SessionBootCommand) { + let mut loader = self.loader.write().await; + loader.boot_command = Some(command); + loader.status = None; + } + + pub async fn boot_command(&self) -> Option { + self.loader.read().await.boot_command.clone() + } + + pub async fn update_loader_status( + &self, + registration_id: String, + boot_id: &str, + status: LoaderStatusPhase, + ) -> Result<(), LoaderStatusUpdateError> { + let session_id = self.info.read().await.id.clone(); + let mut loader = self.loader.write().await; + let Some(command) = loader.boot_command.as_ref() else { + return Err(LoaderStatusUpdateError::NoBootCommand); + }; + if command.boot_id != boot_id { + return Err(LoaderStatusUpdateError::StaleBoot); + } + loader.status = Some(LoaderStatusResponse { + session_id, + boot_id: boot_id.to_string(), + registration_id: Some(registration_id), + status: Some(status), + }); + Ok(()) + } + + pub async fn loader_status(&self) -> Option { + let loader = self.loader.read().await; + let command = loader.boot_command.clone(); + let status = loader.status.clone(); + drop(loader); + if status.is_some() { + return status; + } + let command = command?; + Some(LoaderStatusResponse { + session_id: self.info.read().await.id.clone(), + boot_id: command.boot_id, + registration_id: None, + status: None, + }) + } } impl Drop for SessionState { @@ -280,7 +356,9 @@ async fn run_session_actor( mod tests { use std::thread; - use super::{SESSION_TTL, Session, SessionLifecycleState, SessionState}; + use httpboot_protocol::{BootArch, ImageFormat, LoaderStatusPhase}; + + use super::{SESSION_TTL, Session, SessionBootCommand, SessionLifecycleState, SessionState}; use crate::config::{ BoardConfig, BootConfig, CustomPowerManagement, PowerManagementConfig, PxeProfile, }; @@ -296,6 +374,7 @@ mod tests { power_off_cmd: "echo off".into(), }), boot: BootConfig::Pxe(PxeProfile::default()), + network_identity: None, notes: None, disabled: false, } @@ -327,4 +406,33 @@ mod tests { assert!(!state.begin_release()); assert!(state.is_releasing()); } + + #[tokio::test] + async fn publishing_a_new_boot_atomically_discards_the_old_generation_status() { + let state = SessionState::new(sample_board(), None); + let command = |boot_id: &str| SessionBootCommand { + boot_id: boot_id.into(), + kernel_path: "/kernel.elf".into(), + kernel_size: 4096, + kernel_sha256: "00".repeat(32), + arch: BootArch::X86_64, + image_format: ImageFormat::Elf64, + entry_symbol: None, + }; + state.publish_boot_command(command("boot-1")).await; + state + .update_loader_status( + "registration-1".into(), + "boot-1", + LoaderStatusPhase::Verified, + ) + .await + .unwrap(); + + state.publish_boot_command(command("boot-2")).await; + let status = state.loader_status().await.unwrap(); + assert_eq!(status.boot_id, "boot-2"); + assert_eq!(status.registration_id, None); + assert_eq!(status.status, None); + } } diff --git a/ostool-server/src/state.rs b/ostool-server/src/state.rs index ff11e1e9..b388546c 100644 --- a/ostool-server/src/state.rs +++ b/ostool-server/src/state.rs @@ -6,18 +6,21 @@ use std::{ time::Duration, }; +use anyhow::Context; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use tokio::sync::{RwLock, mpsc}; +use tokio::sync::{Mutex, RwLock, mpsc}; use crate::{ board_pool::{BoardAllocationStatus, allocate_board}, board_store::fs::FileBoardStore, config::{BoardConfig, PowerManagementConfig, ServerConfig}, dtb_store::DtbStore, + loader::LoaderRegistry, power::{PowerAction, PowerActionError, execute_power_action_for_board}, session::{Session, SessionState, SessionStopReason}, tftp::service::TftpManager, + virtual_qemu::VirtualBoardManager, }; const RELEASE_RETRY_ATTEMPTS: usize = 3; @@ -96,6 +99,9 @@ pub struct AppState { pub boards: Arc>>, pub board_runtimes: Arc>>, pub sessions: Arc>>>, + pub loader_registry: LoaderRegistry, + pub virtual_boards: VirtualBoardManager, + pub(crate) board_inventory_gate: Arc>, pub board_store: Arc, pub dtb_store: Arc, pub tftp_manager: Arc>>, @@ -115,12 +121,33 @@ pub async fn build_app_state( let board_runtimes = initial_board_runtimes(&boards); let (release_tx, release_rx) = mpsc::unbounded_channel(); + let virtual_boards = VirtualBoardManager::new(config.virtual_qemu.clone()); + if virtual_boards.enabled() { + for board in boards.values() { + if let PowerManagementConfig::Qemu { virtual_device_id } = &board.power_management { + let mac_address = board + .network_identity + .as_ref() + .expect("validated QEMU boards have a MAC") + .mac_address; + virtual_boards + .ensure_device(virtual_device_id, mac_address) + .await + .with_context(|| { + format!("failed to restore virtual device for board `{}`", board.id) + })?; + } + } + } let state = AppState { config_path: Arc::new(config_path), config: Arc::new(RwLock::new(config)), boards: Arc::new(RwLock::new(boards)), board_runtimes: Arc::new(RwLock::new(board_runtimes)), sessions: Arc::new(RwLock::new(BTreeMap::new())), + loader_registry: LoaderRegistry::new(), + virtual_boards, + board_inventory_gate: Arc::new(Mutex::new(())), board_store, dtb_store, tftp_manager: Arc::new(RwLock::new(tftp_manager)), @@ -161,6 +188,7 @@ impl AppState { client_name: Option, ) -> Result { loop { + let _inventory_guard = self.board_inventory_gate.lock().await; let boards = self.boards.read().await; let runtimes = self.board_runtimes.read().await; let unavailable_board_ids = runtimes @@ -261,8 +289,15 @@ impl AppState { } pub async fn board_power_status(&self, board_id: &str) -> Option { - if !self.boards.read().await.contains_key(board_id) { - return None; + let board = self.boards.read().await.get(board_id).cloned()?; + if let PowerManagementConfig::Qemu { virtual_device_id } = &board.power_management { + let snapshot = self.virtual_boards.snapshot(virtual_device_id).await; + return Some(BoardPowerStatusSnapshot { + available: snapshot.is_some(), + powered: snapshot.as_ref().map(|snapshot| snapshot.powered), + last_action: None, + updated_at: None, + }); } Some(BoardPowerStatusSnapshot { available: false, @@ -287,6 +322,30 @@ impl AppState { board: &BoardConfig, action: PowerAction, ) -> Result { + if let PowerManagementConfig::Qemu { virtual_device_id } = &board.power_management { + return match action { + PowerAction::On => { + let mac = board + .network_identity + .as_ref() + .ok_or_else(|| { + PowerActionError::InvalidConfig( + "QEMU board has no network identity".into(), + ) + })? + .mac_address; + self.virtual_boards + .power_on(virtual_device_id, mac) + .await + .map_err(PowerActionError::Execution) + } + PowerAction::Off => self + .virtual_boards + .power_off(virtual_device_id) + .await + .map_err(PowerActionError::Execution), + }; + } execute_power_action_for_board(board, action).await } @@ -683,6 +742,7 @@ mod tests { power_off_cmd: "echo off".into(), }), boot: BootConfig::Pxe(PxeProfile::default()), + network_identity: None, notes: None, disabled: false, } @@ -975,6 +1035,7 @@ mod tests { power_off_cmd: format!("printf off >> {}", power_log.display()), }), boot: BootConfig::Pxe(PxeProfile::default()), + network_identity: None, notes: None, disabled: false, }; @@ -1041,6 +1102,7 @@ mod tests { power_off_cmd: "printf off >/dev/null".into(), }), boot: BootConfig::Pxe(PxeProfile::default()), + network_identity: None, notes: None, disabled: false, }; @@ -1096,6 +1158,7 @@ mod tests { power_off_cmd: format!("printf 'off\\n' >> {}", power_log.display()), }), boot: BootConfig::Pxe(PxeProfile::default()), + network_identity: None, notes: None, disabled: false, }; @@ -1144,6 +1207,7 @@ mod tests { }, ), boot: BootConfig::Pxe(PxeProfile::default()), + network_identity: None, notes: None, disabled: false, }; diff --git a/ostool-server/src/virtual_lab.rs b/ostool-server/src/virtual_lab.rs new file mode 100644 index 00000000..0ec1dee1 --- /dev/null +++ b/ostool-server/src/virtual_lab.rs @@ -0,0 +1,351 @@ +use std::{fs, os::unix::ffi::OsStrExt as _, path::PathBuf, time::Duration}; + +use anyhow::{Context, bail}; +use tokio::process::Command; + +use crate::config::VirtualQemuConfig; + +const HOST_VETH: &str = "ostool-host0"; +const NAMESPACE_VETH: &str = "ostool-ns0"; +const SERVER_CIDR: &str = "10.77.0.1/24"; +const DHCP_CIDR: &str = "10.77.0.254/24"; +const DHCP_RANGE: &str = "10.77.0.100,10.77.0.200,255.255.255.0,12h"; + +#[derive(Debug, Clone, Copy)] +pub enum VirtualLabAction { + Up, + Status, + Down, +} + +pub async fn run_virtual_lab( + config: &VirtualQemuConfig, + action: VirtualLabAction, +) -> anyhow::Result<()> { + let _lock = LabLock::acquire(config).await?; + match action { + VirtualLabAction::Up => up(config).await, + VirtualLabAction::Status => status(config).await, + VirtualLabAction::Down => down(config).await, + } +} + +async fn up(config: &VirtualQemuConfig) -> anyhow::Result<()> { + if !config.enabled { + bail!("virtual_qemu.enabled must be true before starting the virtual lab"); + } + if namespace_exists(&config.network_namespace).await? { + if status(config).await.is_ok() { + return Ok(()); + } + down(config).await?; + } + + let result = create_lab(config).await; + if result.is_err() { + let _ = down(config).await; + } + result?; + status(config).await +} + +async fn create_lab(config: &VirtualQemuConfig) -> anyhow::Result<()> { + let username = invoking_username().await?; + let groupname = command_output("id", &["-gn", &username]) + .await + .context("failed to resolve the virtual lab owner group")?; + tokio::fs::create_dir_all(lab_runtime_dir(config)).await?; + + run("ip", &["netns", "add", &config.network_namespace]).await?; + run( + "ip", + &[ + "link", + "add", + HOST_VETH, + "type", + "veth", + "peer", + "name", + NAMESPACE_VETH, + ], + ) + .await?; + run( + "ip", + &[ + "link", + "set", + NAMESPACE_VETH, + "netns", + &config.network_namespace, + ], + ) + .await?; + run("ip", &["addr", "add", SERVER_CIDR, "dev", HOST_VETH]).await?; + run("ip", &["link", "set", HOST_VETH, "up"]).await?; + + run_in_namespace(config, &["link", "add", &config.bridge, "type", "bridge"]).await?; + run_in_namespace(config, &["addr", "add", DHCP_CIDR, "dev", &config.bridge]).await?; + run_in_namespace(config, &["link", "set", &config.bridge, "up"]).await?; + run_in_namespace( + config, + &["link", "set", NAMESPACE_VETH, "master", &config.bridge], + ) + .await?; + run_in_namespace(config, &["link", "set", NAMESPACE_VETH, "up"]).await?; + + for tap in &config.tap_pool { + run_in_namespace( + config, + &[ + "tuntap", "add", "dev", tap, "mode", "tap", "user", &username, + ], + ) + .await?; + run_in_namespace(config, &["link", "set", tap, "master", &config.bridge]).await?; + run_in_namespace(config, &["link", "set", tap, "up"]).await?; + } + + let pid_file = dnsmasq_pid_file(config); + let lease_file = dnsmasq_lease_file(config); + let status = Command::new("ip") + .args(["netns", "exec", &config.network_namespace, "dnsmasq"]) + .arg("--conf-file=") + .arg(format!("--interface={}", config.bridge)) + .arg("--bind-interfaces") + .arg(format!("--user={username}")) + .arg(format!("--group={groupname}")) + .arg("--listen-address=10.77.0.254") + .arg(format!("--dhcp-range={DHCP_RANGE}")) + .arg("--dhcp-option=3,10.77.0.1") + .arg("--dhcp-option=6,10.77.0.254") + .arg(format!("--pid-file={}", pid_file.display())) + .arg(format!("--dhcp-leasefile={}", lease_file.display())) + .status() + .await + .context("failed to execute dnsmasq")?; + if !status.success() { + bail!("dnsmasq exited with {status}"); + } + for _ in 0..20 { + if dnsmasq_process(config).await.is_ok() { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + dnsmasq_process(config).await?; + Ok(()) +} + +async fn invoking_username() -> anyhow::Result { + if let Some(username) = std::env::var_os("SUDO_USER") + .and_then(|username| username.into_string().ok()) + .filter(|username| !username.trim().is_empty() && username != "root") + { + command_output("id", &["-u", &username]) + .await + .context("SUDO_USER does not name a valid account")?; + return Ok(username); + } + command_output("id", &["-un"]).await +} + +async fn status(config: &VirtualQemuConfig) -> anyhow::Result<()> { + if !namespace_exists(&config.network_namespace).await? { + bail!("virtual lab `{}` is down", config.network_namespace); + } + run("ip", &["link", "show", HOST_VETH]).await?; + run_in_namespace(config, &["link", "show", &config.bridge]).await?; + for tap in &config.tap_pool { + run_in_namespace(config, &["link", "show", tap]).await?; + } + dnsmasq_process(config).await?; + println!( + "virtual lab {} is up; server=10.77.0.1, bridge={}, taps={}", + config.network_namespace, + config.bridge, + config.tap_pool.join(",") + ); + Ok(()) +} + +async fn down(config: &VirtualQemuConfig) -> anyhow::Result<()> { + let pid_file = dnsmasq_pid_file(config); + if let Ok(pid) = dnsmasq_process(config).await { + let _ = Command::new("kill").arg(pid.to_string()).status().await; + } + match tokio::fs::remove_file(&pid_file).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + match tokio::fs::remove_file(dnsmasq_lease_file(config)).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + if namespace_exists(&config.network_namespace).await? { + run("ip", &["netns", "delete", &config.network_namespace]).await?; + } + if link_exists(HOST_VETH).await? { + run("ip", &["link", "delete", HOST_VETH]).await?; + } + println!("virtual lab {} is down", config.network_namespace); + Ok(()) +} + +async fn namespace_exists(namespace: &str) -> anyhow::Result { + Ok(Command::new("ip") + .args(["netns", "exec", namespace, "true"]) + .status() + .await + .context("failed to execute ip")? + .success()) +} + +async fn link_exists(link: &str) -> anyhow::Result { + Ok(Command::new("ip") + .args(["link", "show", link]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await + .context("failed to execute ip")? + .success()) +} + +async fn run_in_namespace(config: &VirtualQemuConfig, args: &[&str]) -> anyhow::Result<()> { + let mut full_args = vec!["netns", "exec", config.network_namespace.as_str(), "ip"]; + full_args.extend_from_slice(args); + run("ip", &full_args).await +} + +async fn run(program: &str, args: &[&str]) -> anyhow::Result<()> { + let status = Command::new(program) + .args(args) + .status() + .await + .with_context(|| format!("failed to execute {program}"))?; + if !status.success() { + bail!("{program} {} exited with {status}", args.join(" ")); + } + Ok(()) +} + +async fn command_output(program: &str, args: &[&str]) -> anyhow::Result { + let output = Command::new(program).args(args).output().await?; + if !output.status.success() { + bail!("{program} {} exited with {}", args.join(" "), output.status); + } + String::from_utf8(output.stdout) + .context("command output was not UTF-8") + .map(|output| output.trim().to_string()) +} + +fn lab_runtime_dir(config: &VirtualQemuConfig) -> PathBuf { + config.runtime_dir.join("lab") +} + +fn dnsmasq_pid_file(config: &VirtualQemuConfig) -> PathBuf { + lab_runtime_dir(config).join("dnsmasq.pid") +} + +fn dnsmasq_lease_file(config: &VirtualQemuConfig) -> PathBuf { + lab_runtime_dir(config).join("dnsmasq.leases") +} + +async fn dnsmasq_process(config: &VirtualQemuConfig) -> anyhow::Result { + let pid_file = dnsmasq_pid_file(config); + let pid = tokio::fs::read_to_string(&pid_file) + .await + .with_context(|| format!("failed to read {}", pid_file.display()))? + .trim() + .parse::() + .context("dnsmasq pid file did not contain a process ID")?; + let cmdline = tokio::fs::read(format!("/proc/{pid}/cmdline")) + .await + .context("dnsmasq process is not running")?; + if !is_expected_dnsmasq_cmdline(&cmdline, &pid_file) { + bail!("process {pid} from the virtual lab pid file is not its dnsmasq"); + } + let namespace_pids = + command_output("ip", &["netns", "pids", config.network_namespace.as_str()]).await?; + if !namespace_pids + .lines() + .filter_map(|line| line.trim().parse::().ok()) + .any(|namespace_pid| namespace_pid == pid) + { + bail!( + "dnsmasq process {pid} is not in network namespace `{}`", + config.network_namespace + ); + } + Ok(pid) +} + +fn is_expected_dnsmasq_cmdline(cmdline: &[u8], pid_file: &std::path::Path) -> bool { + let expected_pid_file = format!("--pid-file={}", pid_file.display()); + let mut arguments = cmdline.split(|byte| *byte == 0); + let executable_is_dnsmasq = arguments + .next() + .and_then(|argument| { + std::path::Path::new(std::ffi::OsStr::from_bytes(argument)).file_name() + }) + .is_some_and(|name| name == "dnsmasq"); + executable_is_dnsmasq && arguments.any(|argument| argument == expected_pid_file.as_bytes()) +} + +struct LabLock(fs::File); + +impl LabLock { + async fn acquire(config: &VirtualQemuConfig) -> anyhow::Result { + let path = lab_runtime_dir(config).join("lifecycle.lock"); + tokio::task::spawn_blocking(move || { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let file = fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path) + .with_context(|| format!("failed to open {}", path.display()))?; + fs4::FileExt::lock(&file) + .with_context(|| format!("failed to lock {}", path.display()))?; + Ok::<_, anyhow::Error>(Self(file)) + }) + .await + .context("virtual lab lock task failed")? + } +} + +impl Drop for LabLock { + fn drop(&mut self) { + let _ = fs4::FileExt::unlock(&self.0); + } +} + +#[cfg(test)] +mod tests { + use super::is_expected_dnsmasq_cmdline; + + #[test] + fn dnsmasq_pid_must_belong_to_the_expected_command() { + let pid_file = std::path::Path::new("/run/ostool/dnsmasq.pid"); + assert!(is_expected_dnsmasq_cmdline( + b"/usr/sbin/dnsmasq\0--pid-file=/run/ostool/dnsmasq.pid\0", + pid_file + )); + assert!(!is_expected_dnsmasq_cmdline( + b"/usr/bin/unrelated\0--pid-file=/run/ostool/dnsmasq.pid\0", + pid_file + )); + assert!(!is_expected_dnsmasq_cmdline( + b"/usr/sbin/dnsmasq\0--pid-file=/run/other.pid\0", + pid_file + )); + } +} diff --git a/ostool-server/src/virtual_qemu.rs b/ostool-server/src/virtual_qemu.rs new file mode 100644 index 00000000..cfd11af4 --- /dev/null +++ b/ostool-server/src/virtual_qemu.rs @@ -0,0 +1,721 @@ +use std::{ + collections::{BTreeMap, VecDeque}, + net::{Ipv4Addr, SocketAddr}, + path::{Path, PathBuf}, + process::Stdio, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::Duration, +}; + +use anyhow::{Context, bail}; +use httpboot_protocol::MacAddress; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt, DuplexStream}, + net::{TcpListener, UnixStream}, + process::{Child, Command}, + sync::{Mutex, RwLock, broadcast, mpsc, watch}, + task::JoinHandle, + time::{Instant, sleep, timeout}, +}; + +use crate::config::VirtualQemuConfig; + +const SERIAL_HISTORY_LIMIT: usize = 64 * 1024; +const QEMU_START_TIMEOUT: Duration = Duration::from_secs(10); +const QEMU_STOP_TIMEOUT: Duration = Duration::from_secs(3); +const VIRTUAL_SERVER_IP: Ipv4Addr = Ipv4Addr::new(10, 77, 0, 1); + +#[derive(Debug, Clone)] +pub struct VirtualDeviceSnapshot { + pub id: String, + pub mac_address: MacAddress, + pub tap: String, + pub powered: bool, + pub serial_connected: bool, + pub generation: u64, +} + +#[derive(Clone)] +pub struct VirtualBoardManager { + config: Arc, + devices: Arc>>>, +} + +struct VirtualDevice { + id: String, + mac_address: MacAddress, + tap: String, + hub: SerialHub, + runtime: Mutex, +} + +struct VirtualRuntime { + child: Option, + deleting: bool, + generation: u64, + run_dir: PathBuf, + qmp_path: PathBuf, +} + +#[derive(Clone)] +struct SerialHub { + address: SocketAddr, + history: Arc>>, + output_tx: broadcast::Sender>, + input_tx: watch::Sender>>>, + listener_task: Arc>>>, + connection_generation: Arc, +} + +impl VirtualBoardManager { + pub fn new(config: VirtualQemuConfig) -> Self { + Self { + config: Arc::new(config), + devices: Arc::new(RwLock::new(BTreeMap::new())), + } + } + + pub fn enabled(&self) -> bool { + self.config.enabled + } + + pub async fn create_device( + &self, + requested_mac: Option, + ) -> anyhow::Result { + if !self.enabled() { + bail!("virtual QEMU support is disabled"); + } + + let id = uuid::Uuid::new_v4().to_string(); + let mac_address = requested_mac.unwrap_or_else(|| generated_mac(&id)); + let device = self.get_or_create_device(&id, mac_address).await?; + if let Err(error) = self.power_on_device(&device).await { + self.devices.write().await.remove(&device.id); + device.hub.shutdown().await; + let run_dir = device.runtime.lock().await.run_dir.clone(); + if let Err(cleanup_error) = tokio::fs::remove_dir_all(&run_dir).await + && cleanup_error.kind() != std::io::ErrorKind::NotFound + { + log::warn!( + "failed to clean up virtual device `{}` after start failure: {cleanup_error}", + device.id + ); + } + return Err(error); + } + self.snapshot_device(&device).await + } + + pub(crate) async fn ensure_device( + &self, + id: &str, + mac_address: MacAddress, + ) -> anyhow::Result<()> { + self.get_or_create_device(id, mac_address).await?; + Ok(()) + } + + #[cfg(test)] + pub(crate) async fn insert_test_device( + &self, + id: &str, + mac_address: MacAddress, + ) -> anyhow::Result<()> { + if self.devices.read().await.contains_key(id) { + bail!("virtual device `{id}` already exists"); + } + let hub = SerialHub::new_at(Ipv4Addr::LOCALHOST).await?; + let run_dir = self.config.runtime_dir.join(id); + self.devices.write().await.insert( + id.to_string(), + Arc::new(VirtualDevice { + id: id.to_string(), + mac_address, + tap: self + .config + .tap_pool + .first() + .cloned() + .unwrap_or_else(|| "test-tap".into()), + hub, + runtime: Mutex::new(VirtualRuntime { + child: None, + deleting: false, + generation: 0, + qmp_path: run_dir.join("qmp.sock"), + run_dir, + }), + }), + ); + Ok(()) + } + + async fn get_or_create_device( + &self, + id: &str, + mac_address: MacAddress, + ) -> anyhow::Result> { + if !self.enabled() { + bail!("virtual QEMU support is disabled"); + } + + let mut devices = self.devices.write().await; + if let Some(device) = devices.get(id) { + if device.mac_address != mac_address { + bail!("virtual device `{id}` is configured with conflicting MAC addresses"); + } + return Ok(device.clone()); + } + if devices + .values() + .any(|device| device.mac_address == mac_address) + { + bail!("virtual device MAC {mac_address} already exists"); + } + let used_taps = devices + .values() + .map(|device| device.tap.as_str()) + .collect::>(); + let tap = self + .config + .tap_pool + .iter() + .find(|tap| !used_taps.contains(&tap.as_str())) + .cloned() + .context("no free TAP device in virtual_qemu.tap_pool")?; + let hub = SerialHub::new().await?; + let run_dir = self.config.runtime_dir.join(id); + let device = Arc::new(VirtualDevice { + id: id.to_string(), + mac_address, + tap, + hub, + runtime: Mutex::new(VirtualRuntime { + child: None, + deleting: false, + generation: 0, + qmp_path: run_dir.join("qmp.sock"), + run_dir, + }), + }); + devices.insert(id.to_string(), device.clone()); + Ok(device) + } + + pub async fn delete_device(&self, id: &str) -> anyhow::Result<()> { + let device = { + let mut devices = self.devices.write().await; + let device = devices + .get(id) + .cloned() + .with_context(|| format!("virtual device `{id}` does not exist"))?; + // Callers that already cloned this Arc must observe the terminal + // state after acquiring the runtime lock instead of restarting QEMU. + device.runtime.lock().await.deleting = true; + devices.remove(id); + device + }; + if let Err(error) = self.power_off_device(&device).await { + device.runtime.lock().await.deleting = false; + self.devices.write().await.insert(id.to_string(), device); + return Err(error); + } + device.hub.shutdown().await; + let run_dir = device.runtime.lock().await.run_dir.clone(); + match tokio::fs::remove_dir_all(&run_dir).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).context("failed to remove virtual device runtime"), + } + Ok(()) + } + + pub async fn snapshots(&self) -> Vec { + let devices = self + .devices + .read() + .await + .values() + .cloned() + .collect::>(); + let mut snapshots = Vec::with_capacity(devices.len()); + for device in devices { + if let Ok(snapshot) = self.snapshot_device(&device).await { + snapshots.push(snapshot); + } + } + snapshots.sort_by(|left, right| left.id.cmp(&right.id)); + snapshots + } + + pub async fn snapshot(&self, id: &str) -> Option { + let device = self.devices.read().await.get(id).cloned()?; + self.snapshot_device(&device).await.ok() + } + + pub async fn power_on(&self, id: &str, expected_mac: MacAddress) -> anyhow::Result { + let device = self.device(id).await?; + if device.mac_address != expected_mac { + bail!( + "virtual device `{id}` has MAC {}, not {expected_mac}", + device.mac_address + ); + } + self.power_on_device(&device).await?; + Ok(format!("virtual QEMU device `{id}` is on")) + } + + pub async fn power_off(&self, id: &str) -> anyhow::Result { + let device = self.device(id).await?; + self.power_off_device(&device).await?; + Ok(format!("virtual QEMU device `{id}` is off")) + } + + pub async fn attach_serial(&self, id: &str) -> anyhow::Result { + self.device(id).await?.hub.attach().await + } + + pub async fn shutdown(&self) { + let devices = self + .devices + .read() + .await + .values() + .cloned() + .collect::>(); + for device in devices { + if let Err(error) = self.power_off_device(&device).await { + log::warn!("failed to stop virtual device `{}`: {error:#}", device.id); + } + device.hub.shutdown().await; + } + } + + async fn device(&self, id: &str) -> anyhow::Result> { + self.devices + .read() + .await + .get(id) + .cloned() + .with_context(|| format!("virtual device `{id}` does not exist")) + } + + async fn snapshot_device( + &self, + device: &VirtualDevice, + ) -> anyhow::Result { + let mut runtime = device.runtime.lock().await; + let powered = child_is_running(&mut runtime.child)?; + Ok(VirtualDeviceSnapshot { + id: device.id.clone(), + mac_address: device.mac_address, + tap: device.tap.clone(), + powered, + serial_connected: device.hub.is_connected().await, + generation: runtime.generation, + }) + } + + async fn power_on_device(&self, device: &VirtualDevice) -> anyhow::Result<()> { + let mut runtime = device.runtime.lock().await; + if runtime.deleting { + bail!("virtual device `{}` is being deleted", device.id); + } + if child_is_running(&mut runtime.child)? { + return Ok(()); + } + + prepare_runtime(&self.config, &runtime.run_dir).await?; + if tokio::fs::try_exists(&runtime.qmp_path).await? { + tokio::fs::remove_file(&runtime.qmp_path).await?; + } + let vars_path = runtime.run_dir.join("OVMF_VARS.fd"); + let esp_path = runtime.run_dir.join("esp"); + let serial_address = device.hub.address(); + let mut command = Command::new("ip"); + command + .arg("netns") + .arg("exec") + .arg(&self.config.network_namespace) + .arg(&self.config.qemu_binary) + .arg("-machine") + .arg("q35,accel=tcg") + .arg("-cpu") + .arg("max") + .arg("-m") + .arg(self.config.memory_mib.to_string()) + .arg("-smp") + .arg(self.config.cpus.to_string()) + .arg("-display") + .arg("none") + .arg("-monitor") + .arg("none") + .arg("-drive") + .arg(format!( + "if=pflash,format=raw,readonly=on,file={}", + self.config.ovmf_code.display() + )) + .arg("-drive") + .arg(format!("if=pflash,format=raw,file={}", vars_path.display())) + .arg("-drive") + .arg(format!("format=raw,file=fat:rw:{}", esp_path.display())) + .arg("-netdev") + .arg(format!( + "tap,id=net0,ifname={},script=no,downscript=no", + device.tap + )) + .arg("-device") + .arg(format!( + "virtio-net-pci,netdev=net0,mac={}", + device.mac_address + )) + .arg("-chardev") + .arg(format!( + "socket,id=serial0,host={},port={},server=off,reconnect-ms=100", + serial_address.ip(), + serial_address.port() + )) + .arg("-serial") + .arg("chardev:serial0") + .arg("-qmp") + .arg(format!( + "unix:{},server=on,wait=off", + runtime.qmp_path.display() + )) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let serial_generation = device.hub.connection_generation(); + let child = command.spawn().with_context(|| { + format!( + "failed to start virtual QEMU using {}", + self.config.qemu_binary.display() + ) + })?; + runtime.child = Some(child); + runtime.generation += 1; + + let deadline = Instant::now() + QEMU_START_TIMEOUT; + while Instant::now() < deadline { + if !child_is_running(&mut runtime.child)? { + bail!("virtual QEMU exited before its serial channel connected"); + } + if device.hub.connection_generation() > serial_generation + && device.hub.is_connected().await + { + return Ok(()); + } + sleep(Duration::from_millis(50)).await; + } + if let Some(child) = runtime.child.as_mut() { + let _ = child.kill().await; + let _ = child.wait().await; + } + runtime.child = None; + bail!("timed out waiting for virtual QEMU serial connection") + } + + async fn power_off_device(&self, device: &VirtualDevice) -> anyhow::Result<()> { + let mut runtime = device.runtime.lock().await; + if !child_is_running(&mut runtime.child)? { + runtime.child = None; + return Ok(()); + } + + let _ = qmp_quit(&runtime.qmp_path).await; + if let Some(child) = runtime.child.as_mut() + && timeout(QEMU_STOP_TIMEOUT, child.wait()).await.is_err() + { + child + .kill() + .await + .context("failed to terminate virtual QEMU")?; + child.wait().await.context("failed to reap virtual QEMU")?; + } + runtime.child = None; + Ok(()) + } +} + +impl SerialHub { + async fn new() -> anyhow::Result { + Self::new_at(VIRTUAL_SERVER_IP).await + } + + async fn new_at(address: Ipv4Addr) -> anyhow::Result { + let listener = TcpListener::bind((address, 0)).await?; + let address = listener.local_addr()?; + let (output_tx, _) = broadcast::channel(256); + let hub = Self { + address, + history: Arc::new(Mutex::new(VecDeque::with_capacity(SERIAL_HISTORY_LIMIT))), + output_tx, + input_tx: watch::channel(None).0, + listener_task: Arc::new(Mutex::new(None)), + connection_generation: Arc::new(AtomicU64::new(0)), + }; + let listener_hub = hub.clone(); + *hub.listener_task.lock().await = Some(tokio::spawn(async move { + listener_hub.run_listener(listener).await; + })); + Ok(hub) + } + + fn address(&self) -> SocketAddr { + self.address + } + + async fn is_connected(&self) -> bool { + self.input_tx.borrow().is_some() + } + + fn connection_generation(&self) -> u64 { + self.connection_generation.load(Ordering::Acquire) + } + + async fn attach(&self) -> anyhow::Result { + let (client, hub_side) = tokio::io::duplex(SERIAL_HISTORY_LIMIT); + let (mut hub_reader, mut hub_writer) = tokio::io::split(hub_side); + let history = self + .history + .lock() + .await + .iter() + .copied() + .collect::>(); + let mut output_rx = self.output_tx.subscribe(); + let (attachment_closed_tx, mut attachment_closed_rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + if !history.is_empty() && hub_writer.write_all(&history).await.is_err() { + return; + } + loop { + tokio::select! { + _ = &mut attachment_closed_rx => break, + output = output_rx.recv() => match output { + Ok(bytes) if hub_writer.write_all(&bytes).await.is_err() => break, + Ok(_) | Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => break, + } + } + } + }); + let mut input_rx = self.input_tx.subscribe(); + tokio::spawn(async move { + let mut buffer = [0u8; 1024]; + 'attachment: while let Ok(read) = hub_reader.read(&mut buffer).await { + if read == 0 { + break; + } + let bytes = buffer[..read].to_vec(); + loop { + let current = input_rx.borrow().clone(); + if let Some(sender) = current + && sender.send(bytes.clone()).await.is_ok() + { + break; + } + if input_rx.changed().await.is_err() { + break 'attachment; + } + } + } + let _ = attachment_closed_tx.send(()); + }); + Ok(client) + } + + async fn run_listener(&self, listener: TcpListener) { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + let (mut reader, mut writer) = stream.into_split(); + let (input_tx, mut input_rx) = mpsc::channel::>(64); + self.input_tx.send_replace(Some(input_tx)); + self.connection_generation.fetch_add(1, Ordering::AcqRel); + let mut buffer = [0u8; 1024]; + loop { + tokio::select! { + read = reader.read(&mut buffer) => { + match read { + Ok(0) | Err(_) => break, + Ok(read) => { + self.record_output(&buffer[..read]).await; + } + } + } + bytes = input_rx.recv() => { + match bytes { + Some(bytes) if writer.write_all(&bytes).await.is_err() => break, + Some(_) => {} + None => break, + } + } + } + } + self.input_tx.send_replace(None); + } + } + + async fn record_output(&self, bytes: &[u8]) { + let mut history = self.history.lock().await; + let overflow = history.len().saturating_add(bytes.len()) + - history + .len() + .saturating_add(bytes.len()) + .min(SERIAL_HISTORY_LIMIT); + let drain = overflow.min(history.len()); + history.drain(..drain); + history.extend(bytes.iter().copied()); + drop(history); + let _ = self.output_tx.send(bytes.to_vec()); + } + + async fn shutdown(&self) { + if let Some(task) = self.listener_task.lock().await.take() { + task.abort(); + let _ = task.await; + } + self.input_tx.send_replace(None); + } +} + +async fn prepare_runtime(config: &VirtualQemuConfig, run_dir: &Path) -> anyhow::Result<()> { + let efi_dir = run_dir.join("esp/EFI/BOOT"); + tokio::fs::create_dir_all(&efi_dir) + .await + .context("failed to create virtual QEMU ESP")?; + tokio::fs::copy(&config.ovmf_vars, run_dir.join("OVMF_VARS.fd")) + .await + .context("failed to copy OVMF VARS template")?; + tokio::fs::copy(&config.axloader_efi, efi_dir.join("BOOTX64.EFI")) + .await + .context("failed to install axloader into virtual QEMU ESP")?; + Ok(()) +} + +fn child_is_running(child: &mut Option) -> anyhow::Result { + let Some(child) = child.as_mut() else { + return Ok(false); + }; + Ok(child.try_wait()?.is_none()) +} + +async fn qmp_quit(path: &Path) -> anyhow::Result<()> { + let mut stream = UnixStream::connect(path).await?; + let mut greeting = [0u8; 4096]; + let _ = timeout(Duration::from_secs(1), stream.read(&mut greeting)).await; + stream + .write_all(b"{\"execute\":\"qmp_capabilities\"}\r\n") + .await?; + stream.write_all(b"{\"execute\":\"quit\"}\r\n").await?; + stream.flush().await?; + Ok(()) +} + +fn generated_mac(seed: &str) -> MacAddress { + let uuid = uuid::Uuid::parse_str(seed).expect("virtual device IDs are UUIDs"); + let bytes = uuid.as_bytes(); + MacAddress::new([ + (bytes[0] & 0xfe) | 0x02, + bytes[1], + bytes[2], + bytes[3], + bytes[4], + bytes[5], + ]) +} + +#[cfg(test)] +mod tests { + use std::{net::Ipv4Addr, time::Duration}; + + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpStream, + time::sleep, + }; + + use super::{SERIAL_HISTORY_LIMIT, SerialHub, generated_mac}; + + #[test] + fn generated_mac_is_locally_administered_unicast() { + let mac = generated_mac("c69b3747-f23d-4075-8f58-54af9570dc04"); + assert_eq!(mac.octets()[0] & 0b11, 0b10); + } + + #[tokio::test] + async fn serial_hub_keeps_only_the_latest_64_kib() { + let hub = SerialHub::new_at(Ipv4Addr::LOCALHOST).await.unwrap(); + hub.record_output(&vec![1; SERIAL_HISTORY_LIMIT]).await; + hub.record_output(&[2, 3]).await; + let history = hub.history.lock().await; + assert_eq!(history.len(), SERIAL_HISTORY_LIMIT); + assert_eq!(history.front(), Some(&1)); + assert_eq!( + history.iter().rev().take(2).copied().collect::>(), + vec![3, 2] + ); + drop(history); + hub.shutdown().await; + } + + #[tokio::test] + async fn serial_attachment_survives_qemu_reconnect() { + let hub = SerialHub::new_at(Ipv4Addr::LOCALHOST).await.unwrap(); + let mut attachment = hub.attach().await.unwrap(); + let mut first_qemu = TcpStream::connect(hub.address()).await.unwrap(); + wait_until_connected(&hub).await; + + first_qemu.write_all(b"first").await.unwrap(); + let mut first = [0u8; 5]; + attachment.read_exact(&mut first).await.unwrap(); + assert_eq!(&first, b"first"); + drop(first_qemu); + + let mut second_qemu = TcpStream::connect(hub.address()).await.unwrap(); + wait_until_connected(&hub).await; + second_qemu.write_all(b"second").await.unwrap(); + let mut second = [0u8; 6]; + attachment.read_exact(&mut second).await.unwrap(); + assert_eq!(&second, b"second"); + + attachment.write_all(b"input").await.unwrap(); + let mut input = [0u8; 5]; + second_qemu.read_exact(&mut input).await.unwrap(); + assert_eq!(&input, b"input"); + hub.shutdown().await; + } + + #[tokio::test] + async fn serial_input_waits_for_the_first_qemu_connection_without_getting_lost() { + let hub = SerialHub::new_at(Ipv4Addr::LOCALHOST).await.unwrap(); + let mut attachment = hub.attach().await.unwrap(); + attachment.write_all(b"queued-input").await.unwrap(); + + let mut qemu = TcpStream::connect(hub.address()).await.unwrap(); + let mut input = [0u8; 12]; + tokio::time::timeout(Duration::from_secs(1), qemu.read_exact(&mut input)) + .await + .unwrap() + .unwrap(); + assert_eq!(&input, b"queued-input"); + hub.shutdown().await; + } + + async fn wait_until_connected(hub: &SerialHub) { + for _ in 0..100 { + if hub.is_connected().await { + return; + } + sleep(Duration::from_millis(10)).await; + } + panic!("serial hub did not accept the connection"); + } +} diff --git a/ostool-server/tests/session_ws_lifecycle.rs b/ostool-server/tests/session_ws_lifecycle.rs index b67c4cdd..9fac32ee 100644 --- a/ostool-server/tests/session_ws_lifecycle.rs +++ b/ostool-server/tests/session_ws_lifecycle.rs @@ -90,6 +90,7 @@ fn sample_board_with_power_on(serial_port: String, power_on_cmd: String) -> Boar dtb_name: None, ..Default::default() }), + network_identity: None, notes: None, disabled: false, } @@ -124,6 +125,8 @@ fn spawn_test_server_with_power_on( dtb_dir, tftp: TftpConfig::Builtin(tftp), http_boot: ostool_server::config::HttpBootConfig::default_with_root(http_boot_root), + loader_network: ostool_server::LoaderNetworkConfig::default(), + virtual_qemu: ostool_server::VirtualQemuConfig::default(), network: ostool_server::TftpNetworkConfig { interface: "lo".into(), }, diff --git a/ostool-server/webui/e2e/loader-binding.spec.ts b/ostool-server/webui/e2e/loader-binding.spec.ts new file mode 100644 index 00000000..218c29e0 --- /dev/null +++ b/ostool-server/webui/e2e/loader-binding.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from "playwright/test"; + +const loaderDevice = { + mac_address: "02:00:00:00:00:42", + current_mac_address: "02:00:00:00:00:42", + ip_address: "10.77.0.142", + arch: "x86_64", + loader_version: "0.2.0", + hardware: { + manufacturer: "QEMU", + product: "Standard PC (Q35)", + version: "pc-q35", + serial: "virtual-42", + }, + last_seen_at: "2026-09-04T00:00:00Z", + online: true, + conflict: false, + bound_board_id: null, + current_registration_id: "registration-42", +}; + +test("an unbound loader only pre-fills its MAC and reports a duplicate binding", async ({ page }) => { + await page.route("**/api/v1/admin/boards", async (route) => { + if (route.request().method() === "POST") { + await route.fulfill({ + status: 409, + contentType: "application/json", + body: JSON.stringify({ + code: "mac_already_bound", + message: "MAC 地址已绑定到另一块开发板", + }), + }); + return; + } + await route.fulfill({ json: [] }); + }); + await page.route("**/api/v1/admin/sessions", (route) => + route.fulfill({ json: { sessions: [] } }), + ); + await page.route("**/api/v1/admin/loader-devices", (route) => + route.fulfill({ json: [loaderDevice] }), + ); + await page.route("**/api/v1/admin/virtual-devices", (route) => + route.fulfill({ json: { enabled: false, devices: [] } }), + ); + await page.route("**/api/v1/admin/serial-ports", (route) => route.fulfill({ json: [] })); + await page.route("**/api/v1/admin/dtbs", (route) => route.fulfill({ json: [] })); + await page.route("**/api/v1/admin/tftp/status", (route) => + route.fulfill({ + json: { + status: { + resolved_server_ip: "10.77.0.1", + resolved_netmask: "255.255.255.0", + }, + }, + }), + ); + + await page.goto("/admin/boards"); + await expect(page.getByText("Standard PC (Q35)")).toBeVisible(); + await expect(page.getByText("virtual-42")).toBeVisible(); + await page.getByRole("link", { name: "创建配置" }).click(); + + await expect(page.getByLabel("板卡 MAC")).toHaveValue(loaderDevice.mac_address); + await expect(page.getByLabel("板型", { exact: true })).toHaveValue(""); + await expect(page.getByLabel("电源管理类型")).toHaveValue("custom"); + await expect(page.getByLabel("启用串口")).not.toBeChecked(); + + await page.getByLabel("板型", { exact: true }).fill("qemu-x86_64"); + await page.getByLabel("开机命令").fill("true"); + await page.getByLabel("关机命令").fill("true"); + await page.getByRole("button", { name: "保存配置" }).click(); + await expect(page.getByText("MAC 地址已绑定到另一块开发板")).toBeVisible(); +}); diff --git a/ostool-server/webui/package.json b/ostool-server/webui/package.json index 9bd7694d..68b2dcc4 100644 --- a/ostool-server/webui/package.json +++ b/ostool-server/webui/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vue-tsc --noEmit && vite build", - "test": "vitest run" + "test": "vitest run", + "test:e2e": "playwright test" }, "dependencies": { "pinia": "^2.3.1", diff --git a/ostool-server/webui/playwright.config.ts b/ostool-server/webui/playwright.config.ts new file mode 100644 index 00000000..a68090c2 --- /dev/null +++ b/ostool-server/webui/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "playwright/test"; + +const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + use: { + baseURL: "http://127.0.0.1:4174", + browserName: "chromium", + launchOptions: executablePath ? { executablePath } : undefined, + }, + webServer: { + command: "pnpm dev --host 127.0.0.1 --port 4174", + url: "http://127.0.0.1:4174/admin/", + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/ostool-server/webui/src/api/client.ts b/ostool-server/webui/src/api/client.ts index 35c8b9a9..a597a7d1 100644 --- a/ostool-server/webui/src/api/client.ts +++ b/ostool-server/webui/src/api/client.ts @@ -8,10 +8,13 @@ import type { BoardConfig, DtbFileResponse, ErrorResponse, + LoaderDeviceSummary, NetworkInterfaceSummary, SerialPortSummary, TftpConfig, UpdateServerConfigRequest, + VirtualDeviceSummary, + VirtualDevicesResponse, } from "@/types/api"; type RequestOptions = RequestInit & { @@ -61,6 +64,23 @@ export const api = { listBoards() { return request("/api/v1/admin/boards"); }, + listLoaderDevices() { + return request("/api/v1/admin/loader-devices"); + }, + listVirtualDevices() { + return request("/api/v1/admin/virtual-devices"); + }, + createVirtualDevice(macAddress?: string) { + return request("/api/v1/admin/virtual-devices", { + method: "POST", + bodyJson: { mac_address: macAddress || null }, + }); + }, + deleteVirtualDevice(deviceId: string) { + return request(`/api/v1/admin/virtual-devices/${encodeURIComponent(deviceId)}`, { + method: "DELETE", + }); + }, getBoard(boardId: string) { return request(`/api/v1/admin/boards/${encodeURIComponent(boardId)}`); }, diff --git a/ostool-server/webui/src/types/api.ts b/ostool-server/webui/src/types/api.ts index 582950c4..1f89920f 100644 --- a/ostool-server/webui/src/types/api.ts +++ b/ostool-server/webui/src/types/api.ts @@ -47,7 +47,7 @@ export interface TftpStatus { last_error: string | null; } -export type SerialPortKeyKind = "serial_number" | "usb_path"; +export type SerialPortKeyKind = "serial_number" | "usb_path" | "qemu"; export interface SerialPortKey { kind: SerialPortKeyKind; @@ -95,9 +95,15 @@ export interface ZhongshengRelayPowerManagement { key: SerialPortKey; } +export interface QemuPowerManagement { + kind: "qemu"; + virtual_device_id: string; +} + export type PowerManagementConfig = | CustomPowerManagement - | ZhongshengRelayPowerManagement; + | ZhongshengRelayPowerManagement + | QemuPowerManagement; export type UbootNetworkMode = "dhcp" | "static_ip"; @@ -134,6 +140,7 @@ export interface BoardConfig { serial: SerialConfig | null; power_management: PowerManagementConfig; boot: BootConfig; + network_identity: BoardNetworkIdentity | null; notes: string | null; disabled: boolean; } @@ -147,6 +154,46 @@ export interface AdminBoardUpsertRequest { serial: SerialConfig | null; power_management: PowerManagementConfig; boot: BootConfig; + network_identity: BoardNetworkIdentity | null; +} + +export interface BoardNetworkIdentity { + mac_address: string; +} + +export interface LoaderHardwareInfo { + manufacturer: string | null; + product: string | null; + version: string | null; + serial: string | null; +} + +export interface LoaderDeviceSummary { + mac_address: string; + current_mac_address: string; + ip_address: string; + arch: string; + loader_version: string; + hardware: LoaderHardwareInfo; + last_seen_at: string; + online: boolean; + conflict: boolean; + bound_board_id: string | null; + current_registration_id: string | null; +} + +export interface VirtualDeviceSummary { + id: string; + mac_address: string; + tap: string; + powered: boolean; + serial_connected: boolean; + generation: number; +} + +export interface VirtualDevicesResponse { + enabled: boolean; + devices: VirtualDeviceSummary[]; } export interface BoardTypeSummary { diff --git a/ostool-server/webui/src/views/BoardEditorView.test.ts b/ostool-server/webui/src/views/BoardEditorView.test.ts index 2accc408..f0287b84 100644 --- a/ostool-server/webui/src/views/BoardEditorView.test.ts +++ b/ostool-server/webui/src/views/BoardEditorView.test.ts @@ -10,6 +10,8 @@ const route = { const push = vi.fn(); const listSerialPorts = vi.fn(); const listDtbs = vi.fn(); +const listLoaderDevices = vi.fn(); +const listVirtualDevices = vi.fn(); const getTftpStatus = vi.fn(); const createDtb = vi.fn(); const getBoard = vi.fn(); @@ -31,6 +33,8 @@ vi.mock("@/api/client", () => ({ api: { listSerialPorts, listDtbs, + listLoaderDevices, + listVirtualDevices, getTftpStatus, createDtb, getBoard, @@ -95,6 +99,7 @@ function makeBoard(id = "demo-board"): BoardConfig { netmask: null, gatewayip: null, }, + network_identity: null, notes: "rack-a", disabled: false, }; @@ -145,6 +150,9 @@ function makeUefiHttpBoard(id = "uefi-http-board", bootArch = "x86_64"): BoardCo kind: "httpboot", boot_arch: bootArch, }, + network_identity: { + mac_address: "02:00:00:00:00:01", + }, }; } @@ -154,6 +162,8 @@ describe("BoardEditorView", () => { push.mockReset(); listSerialPorts.mockReset(); listDtbs.mockReset(); + listLoaderDevices.mockReset(); + listVirtualDevices.mockReset(); getTftpStatus.mockReset(); createDtb.mockReset(); getBoard.mockReset(); @@ -165,6 +175,8 @@ describe("BoardEditorView", () => { uiStore.setSuccess.mockReset(); listSerialPorts.mockResolvedValue(makeSerialPorts()); listDtbs.mockResolvedValue([]); + listLoaderDevices.mockResolvedValue([]); + listVirtualDevices.mockResolvedValue({ enabled: false, devices: [] }); getTftpStatus.mockResolvedValue({ status: { provider: "builtin", @@ -256,6 +268,7 @@ describe("BoardEditorView", () => { netmask: null, gatewayip: null, }, + network_identity: null, }); expect(uiStore.setSuccess).toHaveBeenCalledWith("已保存开发板 rk3568-1"); expect(push).toHaveBeenCalledWith("/boards/rk3568-1"); @@ -372,7 +385,9 @@ describe("BoardEditorView", () => { expect((wrapper.get('input[placeholder="例如 x86_64"]').element as HTMLInputElement).value).toBe("aarch64"); expect(wrapper.find('input[placeholder="例如 BOOTX64.EFI"]').exists()).toBe(false); expect(wrapper.find('input[placeholder="例如 kernel.bin"]').exists()).toBe(false); - expect(wrapper.find('input[placeholder="例如 1c:69:7a:dc:f3:47"]').exists()).toBe(false); + expect((wrapper.get('input[placeholder="02:00:00:00:00:01"]').element as HTMLInputElement).value).toBe( + "02:00:00:00:00:01", + ); const saveButton = wrapper.findAll("button").find((button) => button.text() === "保存配置"); await saveButton!.trigger("click"); @@ -390,10 +405,48 @@ describe("BoardEditorView", () => { kind: "httpboot", boot_arch: "aarch64", }, + network_identity: { + mac_address: "02:00:00:00:00:01", + }, }), ); }); + it("uses a discovered MAC without filling manual board fields", async () => { + listLoaderDevices.mockResolvedValue([ + { + mac_address: "02:00:00:00:00:09", + current_mac_address: "02:00:00:00:00:09", + ip_address: "10.77.0.9", + arch: "x86_64", + loader_version: "0.2.0", + hardware: { + manufacturer: "QEMU", + product: "Standard PC", + version: "Q35", + serial: "virtual-9", + }, + last_seen_at: "2026-09-04T08:00:00Z", + online: true, + conflict: false, + bound_board_id: null, + current_registration_id: "registration-9", + }, + ]); + const BoardEditorView = (await import("./BoardEditorView.vue")).default; + const wrapper = mount(BoardEditorView); + await flushPromises(); + + const bootMode = wrapper.findAll("select").find((select) => select.text().includes("HTTPboot")); + await bootMode!.setValue("httpboot"); + await wrapper.get('input[placeholder="02:00:00:00:00:01"]').setValue("02:00:00:00:00:09"); + + expect(wrapper.text()).toContain("QEMU Standard PC Q35"); + expect(wrapper.text()).toContain("virtual-9"); + expect((wrapper.get('input[placeholder="例如 rk3568"]').element as HTMLInputElement).value).toBe(""); + expect((wrapper.get('input[placeholder="留空则自动分配 {board type}-{num}"]').element as HTMLInputElement).value).toBe(""); + }); + it("updates a board and keeps blank id as null in the payload", async () => { route.params = { boardId: "demo-board" }; getBoard.mockResolvedValue(makeBoard("demo-board")); diff --git a/ostool-server/webui/src/views/BoardEditorView.vue b/ostool-server/webui/src/views/BoardEditorView.vue index 134126b7..4ca7108a 100644 --- a/ostool-server/webui/src/views/BoardEditorView.vue +++ b/ostool-server/webui/src/views/BoardEditorView.vue @@ -9,13 +9,15 @@ import type { BoardConfig, BootConfig, DtbFileResponse, + LoaderDeviceSummary, PowerManagementConfig, SerialPortKeyKind, SerialPortSummary, UbootNetworkMode, + VirtualDeviceSummary, } from "@/types/api"; -type PowerManagementKind = "custom" | "zhongsheng_relay"; +type PowerManagementKind = "custom" | "zhongsheng_relay" | "qemu"; type BootKind = "uboot" | "pxe" | "httpboot"; interface BoardEditorFormState { @@ -33,8 +35,10 @@ interface BoardEditorFormState { power_off_cmd: string; relay_serial_key_kind: SerialPortKeyKind; relay_serial_key_value: string; + virtual_device_id: string; boot_kind: BootKind; boot_arch: string; + network_mac: string; use_tftp: boolean; dtb_name: string; kernel_load_addr: string; @@ -65,6 +69,8 @@ const validationError = ref(""); const form = ref(defaultFormState()); const serialPorts = ref([]); const dtbs = ref([]); +const loaderDevices = ref([]); +const virtualDevices = ref([]); const tftpStatus = ref<{ resolved_server_ip: string | null; resolved_netmask: string | null } | null>(null); const dtbUploadName = ref(""); const dtbUploadFile = ref(null); @@ -80,6 +86,14 @@ const selectedRelaySerialSummary = computed(() => { const selected = selectedRelaySerialOptionValue(); return serialPorts.value.find((port) => serialOptionValue(port) === selected) ?? null; }); +const selectedLoaderDevice = computed(() => + loaderDevices.value.find((device) => device.mac_address === form.value.network_mac.trim().toLowerCase()) ?? null, +); +const selectableLoaderDevices = computed(() => + loaderDevices.value.filter( + (device) => device.bound_board_id === null || device.bound_board_id === boardId.value, + ), +); function defaultFormState(): BoardEditorFormState { return { @@ -97,8 +111,10 @@ function defaultFormState(): BoardEditorFormState { power_off_cmd: "", relay_serial_key_kind: "serial_number", relay_serial_key_value: "", + virtual_device_id: "", boot_kind: "uboot", boot_arch: "", + network_mac: "", use_tftp: false, dtb_name: "", kernel_load_addr: "", @@ -120,6 +136,7 @@ function boardToFormState(board: BoardConfig): BoardEditorFormState { next.tags_text = board.tags.join(", "); next.notes = board.notes ?? ""; next.disabled = board.disabled; + next.network_mac = board.network_identity?.mac_address ?? ""; if (board.serial) { next.serial_enabled = true; @@ -132,10 +149,13 @@ function boardToFormState(board: BoardConfig): BoardEditorFormState { next.power_management_kind = "custom"; next.power_on_cmd = board.power_management.power_on_cmd; next.power_off_cmd = board.power_management.power_off_cmd; - } else { + } else if (board.power_management.kind === "zhongsheng_relay") { next.power_management_kind = "zhongsheng_relay"; next.relay_serial_key_kind = board.power_management.key.kind; next.relay_serial_key_value = board.power_management.key.value; + } else { + next.power_management_kind = "qemu"; + next.virtual_device_id = board.power_management.virtual_device_id; } if (board.boot.kind === "uboot") { @@ -213,12 +233,19 @@ function buildPowerManagementConfig(): PowerManagementConfig { }; } + if (form.value.power_management_kind === "zhongsheng_relay") { + return { + kind: "zhongsheng_relay", + key: { + kind: form.value.relay_serial_key_kind, + value: form.value.relay_serial_key_value.trim(), + }, + }; + } + return { - kind: "zhongsheng_relay", - key: { - kind: form.value.relay_serial_key_kind, - value: form.value.relay_serial_key_value.trim(), - }, + kind: "qemu", + virtual_device_id: form.value.virtual_device_id.trim(), }; } @@ -240,6 +267,9 @@ function buildRequestPayload(): AdminBoardUpsertRequest { : null, power_management: buildPowerManagementConfig(), boot: buildBootConfig(), + network_identity: trimToNull(form.value.network_mac) + ? { mac_address: form.value.network_mac.trim() } + : null, }; } @@ -269,11 +299,32 @@ function validateForm(): string { if (form.value.power_management_kind === "zhongsheng_relay" && !form.value.relay_serial_key_value.trim()) { errors.push("中盛继电模块必须选择串口设备"); } + if (form.value.power_management_kind === "qemu") { + if (!form.value.virtual_device_id.trim()) { + errors.push("QEMU 电源管理必须选择虚拟设备"); + } + if (!form.value.serial_enabled || form.value.serial_key_kind !== "qemu") { + errors.push("QEMU 电源管理必须启用 QEMU 虚拟串口"); + } else if (form.value.serial_key_value.trim() !== form.value.virtual_device_id.trim()) { + errors.push("QEMU 电源和虚拟串口必须引用同一个虚拟设备"); + } + if (form.value.boot_kind !== "httpboot") { + errors.push("QEMU 虚拟设备必须使用 HTTPboot"); + } + } if (form.value.boot_kind === "uboot" && form.value.use_tftp && form.value.network_mode === "static_ip") { if (!form.value.board_ip.trim()) { errors.push("静态 IP 模式必须填写开发板 IP"); } } + if (form.value.boot_kind === "httpboot") { + const mac = form.value.network_mac.trim(); + if (!mac) { + errors.push("HTTPboot 板卡必须绑定 MAC 地址"); + } else if (!/^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i.test(mac)) { + errors.push("MAC 地址必须是六字节冒号格式,例如 02:00:00:00:00:01"); + } + } return errors.join("\n"); } @@ -315,7 +366,7 @@ function selectedRelaySerialOptionValue() { function parseSerialSelection(value: string): { kind: SerialPortKeyKind; value: string } | null { const [kind, ...rest] = value.split(":"); - if (kind === "serial_number" || kind === "usb_path") { + if (kind === "serial_number" || kind === "usb_path" || kind === "qemu") { return { kind, value: rest.join(":"), @@ -349,9 +400,15 @@ function boardSerialOptions(currentValue: string) { disabled: !port.stable_identity, }); } + for (const device of virtualDevices.value) { + options.set(`qemu:${device.id}`, { + label: `[QEMU] ${device.id} | ${device.mac_address} / ${device.tap}`, + disabled: false, + }); + } const trimmed = currentValue.trim(); if (trimmed && !options.has(trimmed)) { - const keyKind = form.value.serial_key_kind === "serial_number" ? "SN" : "USB PATH"; + const keyKind = serialPrimaryLabel(form.value.serial_key_kind); options.set(trimmed, { label: `[${keyKind}] ${form.value.serial_key_value} (当前配置,未检测到)`, disabled: false, @@ -392,10 +449,28 @@ function relaySerialOptions(currentValue: string) { } function serialPrimaryLabel(kind: SerialPortKeyKind) { + if (kind === "qemu") { + return "QEMU"; + } return kind === "serial_number" ? "SN" : "USB PATH"; } function selectedBoardSerialDescription() { + if (form.value.serial_key_kind === "qemu") { + const device = virtualDevices.value.find( + (candidate) => candidate.id === form.value.serial_key_value.trim(), + ); + return form.value.serial_key_value.trim() + ? { + primaryLabel: "QEMU", + primaryValue: form.value.serial_key_value.trim(), + secondary: device + ? [device.mac_address, device.tap, device.powered ? "运行中" : "已停止"] + : ["当前未检测到对应虚拟设备"], + unresolved: !device, + } + : null; + } if (selectedBoardSerialSummary.value) { const port = selectedBoardSerialSummary.value; const secondary = [ @@ -505,16 +580,24 @@ async function loadEditor() { ui.clearMessages(); try { - const [ports, dtbList, statusResponse, board] = await Promise.all([ + const [ports, dtbList, devices, virtualDeviceList, statusResponse, board] = await Promise.all([ api.listSerialPorts(), api.listDtbs(), + api.listLoaderDevices(), + api.listVirtualDevices(), api.getTftpStatus().catch(() => null), isEditing.value && boardId.value ? api.getBoard(boardId.value) : Promise.resolve(null), ]); serialPorts.value = ports; dtbs.value = dtbList; + loaderDevices.value = devices; + virtualDevices.value = virtualDeviceList.devices; tftpStatus.value = statusResponse?.status ?? null; form.value = board ? boardToFormState(board) : defaultFormState(); + if (!board && typeof route.query?.mac === "string") { + form.value.boot_kind = "httpboot"; + form.value.network_mac = route.query.mac; + } } catch (error) { ui.setError((error as Error).message); } finally { @@ -760,6 +843,7 @@ onMounted(() => { @@ -804,6 +888,17 @@ onMounted(() => { + + @@ -930,10 +1025,46 @@ onMounted(() => {