diff --git a/Cargo.lock b/Cargo.lock index 977f994d..b9d6847c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -290,9 +290,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" dependencies = [ "aws-lc-sys", "zeroize", @@ -300,14 +300,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -613,6 +614,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -1446,11 +1458,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -1460,10 +1470,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -2038,11 +2051,11 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.2", "libc", ] @@ -2636,7 +2649,6 @@ dependencies = [ "toml", "trybuild", "uboot-shell", - "ureq", "url", ] @@ -2657,7 +2669,6 @@ dependencies = [ "mime_guess", "network-interface", "nusb", - "reqwest", "rust-embed", "schemars", "serde", @@ -2958,9 +2969,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -2978,15 +2989,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.2", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -3000,16 +3012,16 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -3060,6 +3072,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -3098,6 +3121,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "ratatui" version = "0.30.1" @@ -3404,9 +3442,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -3437,9 +3475,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", - "log", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -4301,7 +4337,11 @@ checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite 0.28.0", ] @@ -4502,6 +4542,8 @@ dependencies = [ "httparse", "log", "rand 0.9.4", + "rustls", + "rustls-pki-types", "sha1 0.10.6", "thiserror 2.0.18", "utf-8", @@ -4631,35 +4673,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" -dependencies = [ - "base64", - "flate2", - "log", - "percent-encoding", - "rustls", - "rustls-pki-types", - "ureq-proto", - "utf8-zero", - "webpki-roots", -] - -[[package]] -name = "ureq-proto" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" -dependencies = [ - "base64", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -4685,12 +4698,6 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -4904,15 +4911,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -5105,16 +5103,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -5132,31 +5121,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -5165,96 +5137,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "1.0.3" diff --git a/docs/api.md b/docs/api.md index eee37fda..19f6fd05 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,10 +1,17 @@ -# `ostool` 实际调用的服务 API +# `ostool` 后端服务 API -本文根据当前 `ostool` 客户端实现整理认证网关和开发板服务的调用接口;不包含 `ostool-server` 管理后台的 `/api/v1/admin/...` 接口。 +`ostool` 同时支持认证模式和本地局域网模式,两种模式连接不同的后端: -服务地址来自全局或项目配置中的 `board.server`(完整 URL),可被命令行 `--server` 覆盖;可选的 `board.port` 或 `--port` 用于覆盖 URL 中的端口。为兼容旧的局域网配置,`board.server` 为裸 IPv4 或 IPv6 地址时客户端自动补为 `http://`。基线版本写出的 `board.server_ip` / `board.port` 也会在读取时迁移为 `board.server` / `board.port`,下一次保存配置时只写新格式;无 scheme 的主机名不支持。认证网关和 board API 使用同一个 Base URL。 +- 本地局域网模式使用 `auth_mode = "disabled"`,`ostool` 直接连接 `ostool-server`。`ostool-server` 提供 Board REST API、串口 WebSocket、Management API 和管理后台,不提供登录认证功能。 +- 认证模式使用 `auth_mode = "required"`,`ostool` 连接独立的认证后端。该后端提供 OAuth Device Authorization API,并以相同的 Board REST 和串口 WebSocket 契约提供受认证的开发板服务。 -- `auth_mode = "required"` 时,`board.server` 必须使用 HTTPS,所有请求携带下文描述的 Bearer Token; +本文同时记录上述两类后端使用的接口契约,并不表示单个后端实现文中的全部接口。`ostool` CLI 只调用 OAuth、Board REST 和串口 WebSocket;`/api/v1/admin/...` 及管理后台属于 `ostool-server`,供本地管理使用。 + +服务地址来自全局或项目配置中的 `board.server`(完整 URL,可包含路径前缀),可被命令行 `--server` 覆盖;可选的 `board.port` 或 `--port` 用于覆盖 URL 中的端口。`auth_mode = "disabled"` 时该地址指向局域网 `ostool-server`;`auth_mode = "required"` 时该地址指向认证后端,OAuth 和受认证的 Board API 共用这个 Base URL。为兼容旧的局域网配置,`board.server` 为裸 IPv4 或 IPv6 地址时客户端自动补为 `http://`。基线版本写出的 `board.server_ip` / `board.port` 也会在读取时迁移为 `board.server` / `board.port`,下一次保存配置时只写新格式;无 scheme 的主机名不支持。 + +客户端会先把 Base URL 规范化为以 `/` 结尾,再将 HTTP 和 OAuth 接口路径追加到该路径。本文为简洁起见把接口写成 `/api/...` 或 `/oauth/...`;这些是相对于 Base URL 的接口标识,不表示实际请求必须位于域名根目录。例如 Base URL 为 `https://board.example.com/webapp/ostoolmanagephp/` 时,创建会话的实际 URL 是 `https://board.example.com/webapp/ostoolmanagephp/api/v1/sessions`。 + +- `auth_mode = "required"` 时,`board.server` 必须使用 HTTPS,Board REST 请求和串口 WebSocket 握手携带下文描述的 Bearer Token;OAuth Device Authorization、Token 和撤销请求不携带该 Header; - `auth_mode = "disabled"`(默认)时通常使用 HTTP,不会发送认证 Header,适合局域网直连 `ostool-server`。 ## 通用认证规则 @@ -21,7 +28,7 @@ Access Token 选择规则: 2. 否则读取当前 endpoint 保存的一条本地凭据:PAT 直接使用,OAuth Access Token 剩余有效期超过 60 秒时直接使用; 3. OAuth Access Token 剩余有效期不超过 60 秒时,使用保存的 Refresh Token 刷新后再发送请求。 -HTTP 客户端不跟随重定向。认证模式下,绝对 WebSocket URL 必须与 Base URL 使用相同的 scheme、host 和有效端口。 +HTTP 客户端不跟随重定向。认证模式下,绝对 WebSocket URL 必须使用 Base URL 对应的 WebSocket scheme(HTTP 对应 `ws`,HTTPS 对应 `wss`),并与 Base URL 使用相同的 host 和有效端口。 ## 命令索引 @@ -39,7 +46,7 @@ HTTP 客户端不跟随重定向。认证模式下,绝对 WebSocket URL 必须 ## OAuth Device Authorization API -所有 OAuth 请求使用 `application/x-www-form-urlencoded`。 +本节接口只由认证模式下的独立认证后端提供,`ostool-server` 不实现这些路由。所有 OAuth 请求使用 `application/x-www-form-urlencoded`。 ### 基本原理 @@ -128,8 +135,7 @@ grant_type=refresh_token } ``` -`token_type` 必须为 `Bearer`,`expires_in` 必须为正数,`refresh_token` 必须非空。`scope` 为可选字段。 -服务端应在刷新时返回轮换后的 Refresh Token。 +`token_type` 必须为 `Bearer`,`expires_in` 必须为正数,`refresh_token` 必须非空。`scope` 为可选字段。服务端应在刷新时返回轮换后的 Refresh Token。 ### 撤销 OAuth 会话 @@ -157,9 +163,401 @@ token= 刷新时若错误包含 `invalid_grant` 或 `invalid_token`,客户端删除本地 OAuth 凭据并要求重新登录。 +## Management API + +`ostool-server` 的管理页面位于 `/admin`,页面使用同源的 `/api/v1/admin/...` 接口维护开发板、DTB、活动会话、TFTP 和服务器可编辑配置。除 DTB 上传使用原始字节外,请求和响应均使用 JSON。 + +当前 `ostool-server` 路由本身**没有**为管理页面或 Management API 安装认证、授权或 CSRF 中间件,`ostool` 客户端的 `auth_mode` 也不作用于这些接口。生产部署不得将管理监听地址直接暴露到不可信网络;应通过防火墙、内网隔离或带认证的反向代理保护 `/admin` 和 `/api/v1/admin/...`。这些接口可以持久化电源命令、设备路径和服务配置,应按特权管理入口对待。 + +### 接口索引 + +| 功能 | 方法与路径 | +| --- | --- | +| 管理概览 | `GET /api/v1/admin/overview` | +| 开发板列表与创建 | `GET /api/v1/admin/boards`;`POST /api/v1/admin/boards` | +| 单块开发板 | `GET /api/v1/admin/boards/{board_id}`;`PUT /api/v1/admin/boards/{board_id}`;`DELETE /api/v1/admin/boards/{board_id}` | +| 开发板电源状态 | `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` | +| 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}` | +| TFTP 配置 | `GET /api/v1/admin/tftp`;`PUT /api/v1/admin/tftp` | +| TFTP 状态与协调 | `GET /api/v1/admin/tftp/status`;`POST /api/v1/admin/tftp/reconcile` | +| 服务器配置 | `GET /api/v1/admin/server-config`;`PUT /api/v1/admin/server-config` | + +### 管理概览 + +```http +GET /api/v1/admin/overview +``` + +成功返回 `200 OK`: + +```json +{ + "board_count_total": 3, + "board_count_available": 1, + "disabled_board_count": 1, + "active_session_count": 1, + "board_types": [ + { + "board_type": "OrangePi-5-Plus", + "tags": ["arm64", "lab"], + "total": 2, + "available": 1 + } + ], + "tftp_status": { + "provider": "builtin", + "enabled": true, + "healthy": true, + "writable": true, + "resolved_server_ip": "192.168.1.2", + "resolved_netmask": "255.255.255.0", + "root_dir": "/var/lib/ostool-server/tftp-root", + "bind_addr_or_address": "0.0.0.0:69", + "service_state": null, + "last_error": null + }, + "server": { + "listen_addr": "0.0.0.0:2999", + "data_dir": "/var/lib/ostool-server", + "board_dir": "/var/lib/ostool-server/boards", + "dtb_dir": "/var/lib/ostool-server/dtbs", + "http_boot_public_base_url": null, + "dtb_upload_max_mib": 10 + } +} +``` + +`board_count_total` 包含禁用开发板;`board_count_available` 只统计未禁用且租约状态为 `idle` 的开发板。`board_types` 与公开的开发板类型接口使用相同聚合规则,不包含禁用开发板。`active_session_count` 不包含已进入 `releasing` 状态的会话。 + +### 开发板管理 + +```http +GET /api/v1/admin/boards +GET /api/v1/admin/boards/{board_id} +``` + +列表接口返回 `BoardConfig` 数组,单板接口返回一个 `BoardConfig`。读取时服务端会尝试解析串口稳定标识;解析成功后,`serial` 中会额外出现 `resolved_device_path` 和可选的 `resolved_usb_path`。 + +创建和更新使用相同请求结构: + +```http +POST /api/v1/admin/boards +PUT /api/v1/admin/boards/{board_id} +Content-Type: application/json + +{ + "id": null, + "board_type": "OrangePi-5-Plus", + "tags": ["arm64", "lab"], + "notes": "RK3588 development board", + "disabled": false, + "serial": { + "key": { + "kind": "usb_path", + "value": "pci-0000:00:14.0-usb-0:2.1" + }, + "baud_rate": 1500000 + }, + "power_management": { + "kind": "custom", + "power_on_cmd": "board-power OrangePi-5-Plus-1 on", + "power_off_cmd": "board-power OrangePi-5-Plus-1 off" + }, + "boot": { + "kind": "uboot", + "use_tftp": true, + "dtb_name": "rk3588-orangepi-5-plus.dtb", + "kernel_load_addr": "0x00280000", + "fit_load_addr": "0x10000000", + "bootm_addr": "0x10000000", + "network_mode": "dhcp", + "board_ip": null, + "server_ip": null, + "netmask": null, + "gatewayip": null + } +} +``` + +字段约束如下: + +- 创建时 `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`。 +- `power_management.kind` 可为上例的 `custom`,或中盛继电器配置: + + ```json + { + "kind": "zhongsheng_relay", + "key": {"kind": "usb_path", "value": "pci-0000:00:14.0-usb-0:2.2"} + } + ``` + +- `boot.kind` 可为上例的 `uboot`、`{"kind":"pxe","notes":null}`,或 `{"kind":"httpboot","boot_arch":"aarch64"}`。`boot_arch` 可为 `x86_64`、`aarch64`、`loongarch64`、`riscv64` 或 `other`。 +- 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`: + +```http +DELETE /api/v1/admin/boards/{board_id} +``` + +### 开发板状态与硬件发现 + +电源状态和租约状态是只读接口: + +```http +GET /api/v1/admin/boards/{board_id}/power-status +GET /api/v1/admin/boards/{board_id}/runtime-status +``` + +响应示例: + +```json +{ + "available": false, + "powered": null, + "last_action": null, + "updated_at": null +} +``` + +```json +{ + "lease_state": "idle", + "active_session_id": null, + "last_release_error": null, + "updated_at": "2026-07-30T06:00:00Z" +} +``` + +当前 `ostool-server` 只用该接口确认开发板是否存在,尚未维护可查询的实时电源状态,因此对存在的开发板固定返回 `available: false`,其余三个字段为 `null`。响应模型为后续电源状态后端预留了 `powered`、`last_action` 和 `updated_at`;实现这些字段后,`last_action` 可为 `power_on` 或 `power_off`。`lease_state` 可为 `idle`、`using`、`releasing` 或 `error`。 + +硬件发现接口读取服务器当前可见的串口和网络接口: + +```http +GET /api/v1/admin/serial-ports +GET /api/v1/admin/network-interfaces +``` + +串口响应是如下对象的数组: + +```json +[ + { + "current_device_path": "/dev/ttyUSB0", + "port_type": "usb", + "label": "/dev/ttyUSB0 · USB Serial", + "primary_key_kind": "serial_number", + "primary_key_value": "ABC123", + "usb_path": "pci-0000:00:14.0-usb-0:2.1", + "stable_identity": true, + "usb_vendor_id": 6790, + "usb_product_id": 29987, + "manufacturer": "QinHeng Electronics", + "product": "USB Serial", + "serial_number": "ABC123" + } +] +``` + +无法发现的可选字段为 `null`。网络接口响应示例: + +```json +[ + { + "name": "eth0", + "label": "eth0 · 192.168.1.2", + "ipv4_addresses": ["192.168.1.2"], + "netmask": "255.255.255.0", + "loopback": false + } +] +``` + +枚举失败时返回 `503 Service Unavailable`。 + +### DTB 管理 + +```http +GET /api/v1/admin/dtbs +GET /api/v1/admin/dtbs/{dtb_name} +``` + +列表接口返回数组,单文件接口返回: + +```json +{ + "name": "rk3588-orangepi-5-plus.dtb", + "size": 131072, + "updated_at": "2026-07-30T06:00:00Z", + "relative_tftp_path_template": "boot/dtb/rk3588-orangepi-5-plus.dtb" +} +``` + +创建 DTB 使用原始文件体: + +```http +POST /api/v1/admin/dtbs +X-Dtb-Name: rk3588-orangepi-5-plus.dtb +Content-Type: application/octet-stream + + +``` + +文件名必须是合法的单层名称,请求体不能为空且最大为 10 MiB。创建成功返回 `201 Created`;同名文件已存在时返回 `409 Conflict`。 + +更新接口可以重命名、替换内容或同时执行两者: + +```http +PUT /api/v1/admin/dtbs/{dtb_name} +X-Dtb-Name: new-name.dtb # 可选;省略则不重命名 +Content-Type: application/octet-stream + + +``` + +只重命名时允许空请求体;未提供 `X-Dtb-Name` 且没有替换内容时返回 `400 Bad Request`。重命名会同步修改所有开发板 U-Boot 配置中的 `dtb_name` 引用。成功返回更新后的 DTB 元数据。 + +```http +DELETE /api/v1/admin/dtbs/{dtb_name} +``` + +删除成功返回 `204 No Content`。仍被任一开发板引用时返回 `409 Conflict`,不存在时返回 `404 Not Found`。 + +### 活动会话管理 + +```http +GET /api/v1/admin/sessions +``` + +成功返回 `200 OK`: + +```json +{ + "sessions": [ + { + "id": "f0ff8a82-6265-4534-a030-d01df7bc7eb9", + "board_id": "OrangePi-5-Plus-1", + "client_name": "ostool", + "created_at": "2026-07-30T06:00:00Z", + "last_heartbeat_at": "2026-07-30T06:00:05Z", + "expires_at": "2026-07-30T06:00:15Z", + "serial_connected": true, + "state": "active" + } + ] +} +``` + +`state` 可为 `active` 或 `releasing`。管理端释放会话使用: + +```http +DELETE /api/v1/admin/sessions/{session_id} +``` + +成功返回 `202 Accepted` 且没有响应体。释放是异步过程,期间会话可能继续出现在列表中并处于 `releasing`;不存在时返回 `404 Not Found`。 + +### TFTP 管理 + +```http +GET /api/v1/admin/tftp +PUT /api/v1/admin/tftp +Content-Type: application/json +``` + +读取响应使用 `{"tftp": }` 包装;更新请求体直接是 `TftpConfig`。内置 provider 示例: + +```json +{ + "provider": "builtin", + "enabled": true, + "root_dir": "/var/lib/ostool-server/tftp-root", + "bind_addr": "0.0.0.0:69" +} +``` + +systemd `tftpd-hpa` provider 示例: + +```json +{ + "provider": "system_tftpd_hpa", + "enabled": true, + "root_dir": "/srv/tftp", + "config_path": "/etc/default/tftpd-hpa", + "service_name": "tftpd-hpa", + "username": "tftp", + "address": ":69", + "options": "-l -s -c", + "manage_config": false, + "reconcile_on_start": true +} +``` + +更新时服务端创建 `root_dir`、启动新 provider,并对 `system_tftpd_hpa` 立即执行协调;成功后持久化配置并返回 `200 OK`。启动或协调失败时返回 `503 Service Unavailable`,旧 manager 保持生效。 + +```http +GET /api/v1/admin/tftp/status +POST /api/v1/admin/tftp/reconcile +``` + +状态响应使用 `{"status": }` 包装,`TftpStatus` 字段格式与管理概览中的 `tftp_status` 相同。`reconcile` 没有请求体,用于立即应用当前 provider 配置并在成功后返回最新状态;失败返回 `503 Service Unavailable`。 + +### 服务器配置管理 + +```http +GET /api/v1/admin/server-config +``` + +成功返回只读和可编辑配置: + +```json +{ + "readonly": { + "listen_addr": "0.0.0.0:2999", + "data_dir": "/var/lib/ostool-server", + "board_dir": "/var/lib/ostool-server/boards", + "dtb_dir": "/var/lib/ostool-server/dtbs", + "http_boot_public_base_url": null, + "dtb_upload_max_mib": 10 + }, + "editable": { + "network": { + "interface": "eth0" + }, + "upload_limits": { + "session_file_max_mib": 64 + } + } +} +``` + +更新请求只接受可编辑部分: + +```http +PUT /api/v1/admin/server-config +Content-Type: application/json + +{ + "network": { + "interface": "eth0" + }, + "upload_limits": { + "session_file_max_mib": 64 + } +} +``` + +`network.interface` 不能为空,`session_file_max_mib` 必须大于 0。成功后配置立即持久化,并返回与 GET 相同的完整响应。`readonly` 中的监听地址、数据目录、HTTP Boot 公网地址和 DTB 上传上限不能通过此接口修改。 + ## Board REST API -本节覆盖 `ostool-server` 的全部公开、非管理 REST 接口。`ostool` 当前命令直接调用其中的大部分接口;电源控制、HTTP Boot 普通文件上传及会话查询/文件管理端点虽未由当前命令路径调用,仍是 `BoardServerClient` 或公开 board 服务契约的一部分。 +本节定义两种后端共用的开发板服务契约:本地局域网模式由 `ostool-server` 直接提供,认证模式由独立认证后端提供受认证的对应接口。这里覆盖 `ostool-server` 的全部公开、非管理 REST 接口。`ostool` 当前命令会使用会话文件上传,但不会直接调用会话详情、会话文件列表/查询/删除、显式电源控制和普通 HTTP Boot 文件上传;后者仍属于公开 board 服务契约,其中显式电源控制和普通 HTTP Boot 文件上传也已有 `BoardServerClient` 方法。 ### 查询开发板类型 @@ -203,14 +601,20 @@ Content-Type: application/json { "session_id": "...", "board_id": "rk3568-01", - "lease_expires_at": "2026-07-20T02:00:00Z", + "lease_expires_at": "2026-07-20T02:00:10Z", "serial_available": true, "boot_mode": "uboot", - "ws_url": "/api/v1/sessions/.../serial/ws" + "ws_url": "api/v1/sessions/.../serial/ws" } ``` -指定类型不存在时返回 `404`;类型存在但没有符合条件的空闲开发板时返回 `409`。客户端会对后一种情况每秒重试一次,直到分配成功或收到其他错误。 +`ws_url` 在开发板没有串口配置时为 `null`。为兼容包含路径前缀的 Base URL,认证后端应返回不以 `/` 开头的 Base URL 相对路径,或者返回包含完整路径前缀的同源绝对 `ws://`/`wss://` URL。以 `/` 开头的值是 origin-relative URL,只适用于 API 确实部署在域名根目录的情况。 + +`boot_mode` 可为 `uboot`、`pxe` 或 `httpboot`。 + +当前 `ostool-server` 的固定会话 TTL 为 10 秒,每次心跳会把到期时间更新为服务端当前时间之后 10 秒;`ostool` 在成功创建会话后每秒发送一次心跳。独立认证后端可以采用不同 TTL,但必须返回真实的 `lease_expires_at` 并在心跳时续租。 + +指定类型不存在时返回 `404`;类型存在但没有符合条件的空闲开发板时返回 `409`。只有结构化错误中的 `code` 恰好为 `conflict`,且 `message` 与服务端生成的 `no available board for type …` 完全匹配时,当前客户端才会每秒重试;其他 `409` 会直接返回给调用者。 ### 查询会话详情 @@ -228,7 +632,7 @@ GET /api/v1/sessions/{session_id} "client_name": "ostool", "created_at": "2026-07-20T02:00:00Z", "last_heartbeat_at": "2026-07-20T02:00:01Z", - "expires_at": "2026-07-20T02:01:01Z", + "expires_at": "2026-07-20T02:00:11Z", "serial_connected": false, "state": "active" }, @@ -273,12 +677,12 @@ POST /api/v1/sessions/{session_id}/heartbeat DELETE /api/v1/sessions/{session_id} ``` -两个请求均没有请求体。心跳成功返回 `200 OK`: +两个请求均没有请求体。心跳成功返回 `200 OK`;会话正在释放时返回 `409 Conflict`: ```json { "session_id": "...", - "lease_expires_at": "2026-07-20T02:00:00Z" + "lease_expires_at": "2026-07-20T02:00:12Z" } ``` @@ -319,7 +723,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`。顶层 `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`)。客户端还兼容认证后端返回可选 `mac`,但当前 `ostool-server` 不序列化该字段。顶层 `server_ip`、`netmask`、`interface`、`http_base_url` 均可为 `null`。`server_ip` 和 `http_base_url` 使用板端可访问的网络地址,不一定等于管理网地址。 ### 获取串口状态 @@ -335,11 +739,11 @@ GET /api/v1/sessions/{session_id}/serial "connected": false, "port": "/dev/ttyUSB0", "baud_rate": 115200, - "ws_url": "/api/v1/sessions/.../serial/ws" + "ws_url": "api/v1/sessions/.../serial/ws" } ``` -没有串口时,`available` 和 `connected` 为 `false`,`port`、`baud_rate`、`ws_url` 均为 `null`。 +没有串口时,`available` 和 `connected` 为 `false`,`port`、`baud_rate`、`ws_url` 均为 `null`。配置了串口但服务端无法把稳定标识解析为当前设备路径时返回 `503 Service Unavailable`。 ### 获取 TFTP 状态 @@ -352,15 +756,16 @@ GET /api/v1/sessions/{session_id}/tftp ```json { "available": true, - "provider": "internal", + "provider": "builtin", "server_ip": "192.168.1.2", "netmask": "255.255.255.0", "writable": true, "files": [ { "filename": "Image", - "relative_path": "boot/Image", - "tftp_url": "tftp://192.168.1.2/boot/Image", + "relative_path": "ostool/sessions/.../boot/Image", + "tftp_url": "tftp://192.168.1.2/ostool/sessions/.../boot/Image", + "http_url": "http://192.168.1.2:2999/share/sessions/.../boot/Image", "size": 1048576, "uploaded_at": "2026-07-20T02:00:00Z" } @@ -368,7 +773,7 @@ GET /api/v1/sessions/{session_id}/tftp } ``` -`server_ip`、`netmask` 和每个文件的 `tftp_url` 可为 `null`。`available` 表示 TFTP 已启用、健康、可写且能解析服务端 IP。 +`provider` 可为 `builtin` 或 `system_tftpd_hpa`。`server_ip`、`netmask` 以及每个文件的 `tftp_url` 和 `http_url` 可为 `null`。`available` 表示 TFTP 已启用、健康、可写且能解析服务端 IP。文件响应中的 `relative_path` 是相对于 TFTP 根目录的存储路径,包含 `ostool/sessions/{session_id}/`;它不是上传时 `X-File-Path` 使用的会话内相对路径。 ### 获取和下载预置 DTB @@ -382,13 +787,13 @@ GET /api/v1/sessions/{session_id}/dtb/download ```json { "dtb_name": "board.dtb", - "relative_path": "boot/dtb/board.dtb", + "relative_path": "ostool/sessions/.../boot/dtb/board.dtb", "session_file_path": "boot/dtb/board.dtb", - "tftp_url": "tftp://192.168.1.2/boot/dtb/board.dtb" + "tftp_url": "tftp://192.168.1.2/ostool/sessions/.../boot/dtb/board.dtb" } ``` -没有预置 DTB 时上述四个字段均为 `null`。下载接口没有请求体,成功返回 `200 OK`、`Content-Type: application/octet-stream` 和 DTB 原始字节;未配置预置 DTB 或文件不存在时返回 `404`。 +`relative_path` 是相对于 TFTP 根目录的完整存储路径,`session_file_path` 才是会话内路径。没有预置 DTB 时上述四个字段均为 `null`。下载接口没有请求体,成功返回 `200 OK`、`Content-Type: application/octet-stream` 和 DTB 原始字节;未配置预置 DTB 或文件不存在时返回 `404`。 ### 开关机 @@ -417,21 +822,20 @@ X-File-Path: ``` -`X-File-Path` 必填,必须是相对路径;请求体是文件原始字节。成功返回 `201 Created`: +`X-File-Path` 必填,必须是相对于会话根目录的文件路径;绝对路径、`.`/`..` 段以及以 `/` 结尾的路径会被拒绝。请求体是文件原始字节,同一路径再次上传会覆盖原文件。请求体大小受服务器配置 `upload_limits.session_file_max_mib` 限制,超限返回 `413 Payload Too Large`。成功返回 `201 Created`: ```json { "filename": "Image", - "relative_path": "boot/Image", - "tftp_url": "tftp://192.168.1.2/boot/Image", + "relative_path": "ostool/sessions/.../boot/Image", + "tftp_url": "tftp://192.168.1.2/ostool/sessions/.../boot/Image", "http_url": "http://192.168.1.2:2999/share/sessions/.../boot/Image", "size": 1048576, "uploaded_at": "2026-07-20T02:00:00Z" } ``` -`tftp_url` 可为 `null`;`http_url` 使用板端可访问的服务地址。会话文件 HTTP -共享不依赖 TFTP 是否启用。 +响应中的 `relative_path` 是包含 `ostool/sessions/{session_id}/` 的 TFTP 根目录相对路径;上传和后续查询、删除时仍使用会话内路径 `boot/Image`。`tftp_url` 和 `http_url` 均可为 `null`;能够解析板端可访问的服务地址时,`http_url` 使用该地址。会话文件 HTTP 共享不依赖 TFTP 是否启用。 ### 列出、查询和删除会话文件 @@ -441,7 +845,7 @@ GET /api/v1/sessions/{session_id}/files/{path} DELETE /api/v1/sessions/{session_id}/files/{path} ``` -三个请求均没有请求体。前两个请求成功返回 `200 OK`:列表接口返回文件对象数组,单文件接口返回一个文件对象,格式与上传会话文件的成功响应相同。删除成功返回 `204 No Content`。`path` 必须是相对路径。 +三个请求均没有请求体。前两个请求成功返回 `200 OK`:列表接口返回文件对象数组,单文件接口返回一个文件对象,格式与上传会话文件的成功响应相同。删除成功返回 `204 No Content`,文件不存在时删除同样按成功处理。URL 中的 `path` 必须是会话内相对路径,不应使用响应里的完整 `relative_path`。 历史上传路径 `PUT /api/v1/sessions/{session_id}/files/{path}` 被明确拒绝并返回 `404`;上传必须使用前述 `PUT /files` 加 `X-File-Path` Header 的形式。 @@ -452,10 +856,7 @@ GET /share/sessions/{session_id}/{relative_path} Range: bytes=- # 可选 ``` -该端点适用于所有 boot mode,与 TFTP 和 HTTP Boot 开关无关。无 Range 时返回 -`200 OK` 和完整文件;合法单段 Range 返回 `206 Partial Content`。URL 仅在 session -活动期间有效,session 释放、超时或进入 releasing 状态后返回 `404`,对应文件随 -session 清理。 +该端点适用于所有 boot mode,与 TFTP 和 HTTP Boot 开关无关。无 Range 时返回 `200 OK` 和完整文件;合法单段 Range 返回 `206 Partial Content`。与 HTTP Boot 下载相同,当前实现会忽略不合法或不支持的 Range 并返回完整的 `200 OK` 响应,而不是返回 `416`。响应的 `Content-Type` 根据文件名推断。URL 仅在 session 活动期间有效,session 释放、超时或进入 releasing 状态后返回 `404`,对应文件随 session 清理。 ### 上传 HTTP Boot 文件 @@ -466,18 +867,20 @@ X-File-Path: ``` -`X-File-Path` 必填,请求体是文件原始字节。成功返回 `201 Created`: +`X-File-Path` 必填并遵循会话文件上传的相对路径规则,请求体是文件原始字节。该接口仅接受 `boot.kind = "httpboot"` 的活动会话,并要求服务端启用 HTTP Boot;其他 boot mode 返回 `400 Bad Request`,HTTP Boot 被禁用时返回 `409 Conflict`。请求体大小受服务器配置 `upload_limits.session_file_max_mib` 限制,超限返回 `413 Payload Too Large`。成功返回 `201 Created`: ```json { "filename": "kernel.elf", - "relative_path": "kernel.elf", + "relative_path": "ostool/sessions/.../kernel.elf", "http_url": "https://board.example.com/boot/sessions/.../kernel.elf", "size": 1048576, "uploaded_at": "2026-07-20T02:00:00Z" } ``` +响应中的 `relative_path` 同样是包含 `ostool/sessions/{session_id}/` 的存储路径,而不是后续下载 URL 使用的会话内路径。 + ### 下载 HTTP Boot 文件 ```http @@ -485,21 +888,21 @@ GET /boot/sessions/{session_id}/{path} Range: bytes=- # 可选 ``` -该接口供目标机 HTTP Boot 下载已上传文件,请求没有消息体。无 Range 时成功返回 `200 OK` 和文件原始字节;带合法单段 Range 时返回 `206 Partial Content`,并包含 `Content-Range`、`Content-Length`、`Accept-Ranges: bytes`。响应的 `Content-Type` 根据文件名推断。`path` 必须是相对路径。 +该接口供目标机 HTTP Boot 下载已上传文件,请求没有消息体,仅适用于 `boot.kind = "httpboot"` 的活动会话且要求服务端启用 HTTP Boot;其他 boot mode 返回 `400 Bad Request`,HTTP Boot 被禁用时返回 `404 Not Found`。无 Range 时成功返回 `200 OK` 和文件原始字节;带合法单段 Range 时返回 `206 Partial Content`,并包含 `Content-Range`、`Content-Length`、`Accept-Ranges: bytes`。当前实现对不合法或不支持的 Range 不返回 `416`,而是忽略 Range 并返回完整的 `200 OK` 响应。响应的 `Content-Type` 根据文件名推断。`path` 必须是会话内相对路径。会话进入 `releasing` 时返回 `409 Conflict`,会话被移除后返回 `404 Not Found`。 ### 上传 HTTP Boot 内核 ```http PUT /api/v1/sessions/{session_id}/http-boot/kernel X-HttpBoot-Remote-Name: # 可选,默认 kernel.elf -X-HttpBoot-Arch: # 必填:x86_64、aarch64、loongarch64、riscv64 或 other -X-HttpBoot-Image-Format: # 可选,当前仅支持 elf64 -X-HttpBoot-Entry-Symbol: # 可选 +X-HttpBoot-Arch: # 必填:x86_64、aarch64、loongarch64、riscv64 或 other +X-HttpBoot-Image-Format: # 可选,当前仅支持 elf64 +X-HttpBoot-Entry-Symbol: # 可选 ``` -请求体是内核原始字节。成功返回 `201 Created`: +该接口与普通 HTTP Boot 文件上传具有相同的会话类型和服务开关限制。`X-HttpBoot-Remote-Name` 也必须是合法的会话内相对路径。请求体是内核原始字节,大小受服务器配置 `upload_limits.session_file_max_mib` 限制,超限返回 `413 Payload Too Large`。成功返回 `201 Created`: ```json { @@ -510,11 +913,19 @@ X-HttpBoot-Entry-Symbol: # 可选 } ``` -`kernel_sha256` 可为 `null`。当前 `ostool board run` 的 HTTP Boot 流程固定发送 `remote_name=kernel.elf`、`image_format=elf64` 和 `entry_symbol=httpboot_entry`。 +响应模型允许 `kernel_sha256` 为 `null`,但当前 `ostool-server` 会计算并返回 64 位小写十六进制 SHA-256。当前 `ostool board run` 的 HTTP Boot 流程固定发送 `remote_name=kernel.elf`、`image_format=elf64` 和 `entry_symbol=httpboot_entry`。 ## 串口 WebSocket API -会话创建或串口状态响应中的 `ws_url` 用于建立串口连接。相对地址相对于 Base URL 解析;HTTP/HTTPS Base URL 会分别转换为 `ws`/`wss`。 +会话创建或串口状态响应中的 `ws_url` 用于建立串口连接。客户端先将 HTTP/HTTPS Base URL 的 scheme 分别转换为 `ws`/`wss`,再按标准 URL 规则解析 `ws_url`: + +- `ws://` 或 `wss://` 绝对 URL 直接使用;认证模式下必须使用对应的 WebSocket scheme,并与 Base URL 使用相同的 host 和有效端口; +- 不以 `/` 开头的相对路径追加到 Base URL 路径,这是支持带路径前缀部署时应使用的格式; +- 以 `/` 开头的路径从当前 origin 根目录解析并丢弃 Base URL 路径前缀,只能在 API 部署于域名根目录时使用。 + +例如 Base URL 为 `https://www.iamhack.com/webapp/ostoolmanagephp/`,服务端返回 `"api/v1/sessions/.../serial/ws"` 时,客户端连接 `wss://www.iamhack.com/webapp/ostoolmanagephp/api/v1/sessions/.../serial/ws`。 + +当前 `ostool-server` 的响应代码生成 `/api/v1/sessions/.../serial/ws`。这个值只在服务位于 origin 根目录时能保持正确;如果认证后端或反向代理把 API 挂载到路径前缀下,不能原样透传它,必须改为 Base URL 相对路径或带完整前缀的同源绝对 URL。 ```http GET /api/v1/sessions/{session_id}/serial/ws @@ -522,15 +933,45 @@ Upgrade: websocket Authorization: Bearer # 仅 required 模式 ``` -WebSocket 二进制帧承载串口字节流;客户端关闭时会发送: +握手时会话不存在返回 `404 Not Found`;开发板没有串口、会话正在释放或已有串口 WebSocket 连接时返回 `409 Conflict`。每个会话同时只允许一个串口连接。 + +WebSocket 连接成功后服务端打开串口、发送 `opened` 控制消息并自动执行开发板上电。服务端将串口输出作为二进制帧发送,客户端也可直接通过二进制帧写入原始串口字节。 + +服务端文本控制消息如下: + +```json +{"type":"opened"} +``` + +```json +{"type":"closed"} +``` + +```json +{"type":"error","message":"automatic power-on failed: ..."} +``` + +客户端还可以使用 `tx` 文本消息写入 UTF-8 或 Base64 编码的数据;省略 `encoding` 时按 `utf8` 处理: + +```json +{"type":"tx","encoding":"utf8","data":"help\n"} +``` + +```json +{"type":"tx","encoding":"base64","data":"AAE="} +``` + +客户端主动结束串口会话时发送: ```json {"type":"close"} ``` -## Board API 错误响应 +收到客户端 `close`、WebSocket Close 帧、会话释放信号或串口连接结束后,服务端关闭串口并请求释放会话;会话释放流程负责关闭开发板电源。客户端发送 Ping 时服务端回复相同 payload 的 Pong。无法解析的文本消息、缺少 `tx.data` 或不支持的 `encoding` 会终止串口处理。 + +## REST API 错误响应 -非成功的 board REST 响应优先按以下格式解析: +Board REST 和 Management API 业务处理器产生的错误使用以下格式: ```json { @@ -540,4 +981,6 @@ WebSocket 二进制帧承载串口字节流;客户端关闭时会发送: } ``` -`details` 是可选 JSON 值,当前服务端通常返回 `null`;客户端只使用 `code` 和 `message`。任一 board API 返回 `401 Unauthorized` 时,客户端删除当前 endpoint 的本地凭据;不会自动刷新并重试该业务请求。 +`details` 是预留 JSON 字段,当前 `ostool-server` 的 `ApiError` 固定返回 `null`。常见 `code` 包括 `bad_request`、`payload_too_large`、`not_found`、`conflict`、`service_unavailable` 和 `internal_server_error`。请求在进入业务处理器前由 Axum 拒绝时不保证使用此结构,例如无法反序列化 JSON 请求体时可能直接返回 `422 Unprocessable Entity`;WebSocket Upgrade 提取失败时也使用框架自身的错误响应。 + +`ostool` 客户端只使用 Board REST API 错误中的 `code` 和 `message`。任一 Board REST 请求返回 `401 Unauthorized` 时,客户端删除当前 endpoint 的本地凭据;不会自动刷新并重试该业务请求。WebSocket 握手返回 `401` 时当前不会触发该凭据清理逻辑。Management API 本身当前不会生成认证类错误,反向代理增加的认证错误也不保证使用上述 JSON 格式。 diff --git a/ostool-server/Cargo.toml b/ostool-server/Cargo.toml index 330e1eff..503a1b98 100644 --- a/ostool-server/Cargo.toml +++ b/ostool-server/Cargo.toml @@ -53,7 +53,6 @@ url = {workspace = true} uuid = {version = "1", features = ["serde", "v4"]} [dev-dependencies] -reqwest = {version = "0.13", features = ["json"]} tempfile = "3" tokio-modbus = {version = "0.17.0", default-features = false, features = ["rtu", "rtu-server"]} tokio-tungstenite = "0.28" diff --git a/ostool-server/tests/session_ws_lifecycle.rs b/ostool-server/tests/session_ws_lifecycle.rs index 03f0f406..b67c4cdd 100644 --- a/ostool-server/tests/session_ws_lifecycle.rs +++ b/ostool-server/tests/session_ws_lifecycle.rs @@ -11,6 +11,11 @@ use std::{ }; use anyhow::{Context, Result, anyhow, bail}; +use axum::{ + Router, + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, +}; use futures_util::{SinkExt, StreamExt}; use ostool_server::{ BoardConfig, BootConfig, BuiltinTftpConfig, CustomPowerManagement, PowerManagementConfig, @@ -18,10 +23,11 @@ use ostool_server::{ UploadLimitsConfig, build_app_state, build_router, tftp::service::{TftpManager, build_tftp_manager}, }; -use reqwest::StatusCode; use serialport::{SerialPort, TTYPort}; -use tokio::sync::oneshot; +use tokio::{net::TcpStream, sync::oneshot}; use tokio_tungstenite::tungstenite::Message; +use tower::ServiceExt; +use url::Url; const TEST_BOARD_ID: &str = "custom-board-1"; const TEST_BOARD_TYPE: &str = "custom-demo"; @@ -38,6 +44,7 @@ enum ClientShutdownMode { struct TestServerHandle { base_url: String, + app: Router, shutdown_tx: Option>, join: thread::JoinHandle>, } @@ -130,7 +137,7 @@ fn spawn_test_server_with_power_on( std::fs::write(&board_path, toml::to_string_pretty(&board)?) .with_context(|| format!("failed to write {}", board_path.display()))?; - let (addr_tx, addr_rx) = mpsc::channel::>(); + let (addr_tx, addr_rx) = mpsc::channel::>(); let (shutdown_tx, shutdown_rx) = oneshot::channel(); let config_path_for_thread = config_path.clone(); let addr_tx_for_start = addr_tx.clone(); @@ -171,7 +178,7 @@ fn spawn_test_server_with_power_on( let listener = tokio::net::TcpListener::bind(listen_addr).await?; let local_addr = listener.local_addr()?; addr_tx_for_start - .send(Ok(local_addr)) + .send(Ok((local_addr, app.clone()))) .map_err(|_| anyhow!("failed to publish test server listen address"))?; axum::serve(listener, app) @@ -188,14 +195,15 @@ fn spawn_test_server_with_power_on( result }); - let addr = match addr_rx.recv_timeout(Duration::from_secs(5)) { - Ok(Ok(addr)) => addr, + let (addr, app) = match addr_rx.recv_timeout(Duration::from_secs(5)) { + Ok(Ok(server)) => server, Ok(Err(err)) => return Err(anyhow!("test server failed to start: {err}")), Err(_) => return Err(anyhow!("timed out waiting for test server listen address")), }; Ok(TestServerHandle { base_url: format!("http://{addr}"), + app, shutdown_tx: Some(shutdown_tx), join, }) @@ -225,10 +233,9 @@ fn run_delayed_client_write_case() -> Result<()> { .build() .context("failed to build client runtime")?; - let client = reqwest::Client::new(); let (created, mut websocket) = runtime.block_on(async { - wait_for_server_ready(&client, &server.base_url).await?; - let created = create_session(&client, &server.base_url).await?; + wait_for_server_ready(&server.base_url).await?; + let created = create_session(&server.app).await?; let ws_url = resolve_ws_url( &server.base_url, created.ws_url.as_deref().context("missing websocket URL")?, @@ -256,7 +263,7 @@ fn run_delayed_client_write_case() -> Result<()> { .await .context("failed to send websocket close control message")?; wait_for_closed(&mut websocket).await?; - wait_for_session_release(&client, &server.base_url, &created.session_id).await + wait_for_session_release(&server.app, &created.session_id).await })?; server.shutdown() } @@ -317,12 +324,13 @@ fn run_ws_lifecycle_case(mode: ClientShutdownMode) -> Result<()> { let server = spawn_test_server(temp.path(), serial_port)?; let (serial_ready_tx, serial_ready_rx) = mpsc::channel::<()>(); let base_url = server.base_url.clone(); + let app = server.app.clone(); let client_thread = thread::spawn(move || -> Result<()> { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .context("failed to build client runtime")?; - runtime.block_on(run_client_flow(&base_url, mode, serial_ready_tx)) + runtime.block_on(run_client_flow(&app, &base_url, mode, serial_ready_tx)) }); if let Ok(()) = serial_ready_rx.recv_timeout(Duration::from_secs(3)) { @@ -345,14 +353,14 @@ fn run_ws_lifecycle_case(mode: ClientShutdownMode) -> Result<()> { /// Drives one client session through power-on, serial I/O, and release assertions. async fn run_client_flow( + app: &Router, base_url: &str, mode: ClientShutdownMode, serial_ready_tx: mpsc::Sender<()>, ) -> Result<()> { - let client = reqwest::Client::new(); - wait_for_server_ready(&client, base_url).await?; + wait_for_server_ready(base_url).await?; - let created = create_session(&client, base_url).await?; + let created = create_session(app).await?; assert_eq!(created.board_id, TEST_BOARD_ID); let ws_url = resolve_ws_url( base_url, @@ -383,18 +391,21 @@ async fn run_client_flow( } } - wait_for_session_release(&client, base_url, &created.session_id).await?; + wait_for_session_release(app, &created.session_id).await?; Ok(()) } -async fn wait_for_server_ready(client: &reqwest::Client, base_url: &str) -> Result<()> { +async fn wait_for_server_ready(base_url: &str) -> Result<()> { + let base = Url::parse(base_url).with_context(|| format!("invalid base URL `{base_url}`"))?; + let host = base + .host_str() + .ok_or_else(|| anyhow!("test server URL has no host"))?; + let port = base + .port_or_known_default() + .ok_or_else(|| anyhow!("test server URL has no port"))?; let deadline = Instant::now() + Duration::from_secs(5); loop { - let response = client - .get(format!("{base_url}/api/v1/admin/overview")) - .send() - .await; - if matches!(response, Ok(response) if response.status() == StatusCode::OK) { + if TcpStream::connect((host, port)).await.is_ok() { return Ok(()); } if Instant::now() >= deadline { @@ -404,29 +415,36 @@ async fn wait_for_server_ready(client: &reqwest::Client, base_url: &str) -> Resu } } -async fn create_session( - client: &reqwest::Client, - base_url: &str, -) -> Result { - let response = client - .post(format!("{base_url}/api/v1/sessions")) - .json(&serde_json::json!({ - "board_type": TEST_BOARD_TYPE, - "required_tags": [], - "client_name": "integration-test", - })) - .send() +// Exercise REST setup and assertions directly through the router; the listener is only needed for +// the WebSocket lifecycle under test. +async fn create_session(app: &Router) -> Result { + let request = serde_json::json!({ + "board_type": TEST_BOARD_TYPE, + "required_tags": [], + "client_name": "integration-test", + }) + .to_string(); + let response = app + .clone() + .oneshot( + Request::post("/api/v1/sessions") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(request))?, + ) .await - .context("failed to create session")?; + .expect("router service is infallible"); let status = response.status(); - let body = response - .text() + let body = to_bytes(response.into_body(), 1024 * 1024) .await - .context("failed to read session body")?; + .context("failed to read session response")?; if status != StatusCode::CREATED { - bail!("unexpected create session status {status}: {body}"); + bail!( + "unexpected create session status {}: {}", + status, + String::from_utf8_lossy(&body) + ); } - serde_json::from_str(&body).context("failed to parse session response") + serde_json::from_slice(&body).context("failed to parse session response") } async fn wait_for_opened(websocket: &mut S) -> Result<()> @@ -517,38 +535,35 @@ where } } -async fn wait_for_session_release( - client: &reqwest::Client, - base_url: &str, - session_id: &str, -) -> Result<()> { +async fn wait_for_session_release(app: &Router, session_id: &str) -> Result<()> { let deadline = Instant::now() + FAST_ASSERT_TIMEOUT; loop { - let response = client - .get(format!("{base_url}/api/v1/sessions/{session_id}")) - .send() + let response = app + .clone() + .oneshot(Request::get(format!("/api/v1/sessions/{session_id}")).body(Body::empty())?) .await - .with_context(|| format!("failed to query session {session_id}"))?; + .expect("router service is infallible"); let status = response.status(); if status == StatusCode::NOT_FOUND { return Ok(()); } - let body = response.text().await.unwrap_or_default(); if Instant::now() >= deadline { + let body = to_bytes(response.into_body(), 1024 * 1024) + .await + .context("failed to read session response")?; bail!( - "timed out waiting for session `{session_id}` release, last status: {status}, body: {body}" + "timed out waiting for session `{session_id}` release, last status: {status}, body: {}", + String::from_utf8_lossy(&body) ); } tokio::time::sleep(POLL_INTERVAL).await; } } -fn resolve_ws_url(base_url: &str, ws_path: &str) -> Result { - let base = - reqwest::Url::parse(base_url).with_context(|| format!("invalid base URL `{base_url}`"))?; +fn resolve_ws_url(base_url: &str, ws_path: &str) -> Result { + let base = Url::parse(base_url).with_context(|| format!("invalid base URL `{base_url}`"))?; if ws_path.starts_with("ws://") || ws_path.starts_with("wss://") { - return reqwest::Url::parse(ws_path) - .with_context(|| format!("invalid websocket URL `{ws_path}`")); + return Url::parse(ws_path).with_context(|| format!("invalid websocket URL `{ws_path}`")); } let ws_scheme = if base.scheme() == "https" { diff --git a/ostool/Cargo.toml b/ostool/Cargo.toml index b08ed8b2..9dc485d4 100644 --- a/ostool/Cargo.toml +++ b/ostool/Cargo.toml @@ -41,7 +41,14 @@ log = { workspace = true } network-interface = "2" object = "0.39" ratatui = { workspace = true } -reqwest = { version = "0.13", features = ["json", "form"] } +reqwest = { version = "0.13", default-features = false, features = [ + "charset", + "form", + "http2", + "json", + "rustls", + "system-proxy", +] } schemars = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } @@ -58,11 +65,10 @@ regex = "1" sha2 = "0.11" tar = "0.4" thiserror = { workspace = true } -ureq = "3.0" chrono = { version = "0.4.44", features = ["serde"] } fs4 = "1.1" keyring = "4.1" -tokio-tungstenite = "0.28" +tokio-tungstenite = { version = "0.28", features = ["rustls-tls-native-roots"] } tokio-serial = "5.4" [dev-dependencies] diff --git a/ostool/src/auth/client.rs b/ostool/src/auth/client.rs index 8b13639e..93164180 100644 --- a/ostool/src/auth/client.rs +++ b/ostool/src/auth/client.rs @@ -50,7 +50,7 @@ pub struct AuthClient { impl AuthClient { pub fn new(endpoint: BoardEndpoint) -> anyhow::Result { Ok(Self { - client: Client::builder() + client: reqwest::Client::builder() .no_proxy() // Authentication responses must never be redirected to a host that // did not receive the original Device Authorization request. diff --git a/ostool/src/board/serial_stream.rs b/ostool/src/board/serial_stream.rs index b18daaf3..441c09bc 100644 --- a/ostool/src/board/serial_stream.rs +++ b/ostool/src/board/serial_stream.rs @@ -245,7 +245,26 @@ mod tests { use tokio::{sync::Notify, task::JoinHandle}; - use super::{SerialStreamTasks, websocket_request, write_bridge_bytes}; + use super::{SerialStreamTasks, connect_serial_stream, websocket_request, write_bridge_bytes}; + + #[tokio::test] + async fn secure_websocket_support_is_enabled() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + drop(socket); + }); + + let url = reqwest::Url::parse(&format!("wss://{address}/serial")).unwrap(); + let result = connect_serial_stream(url, None).await; + server.await.unwrap(); + + let error = result + .err() + .expect("dummy TLS server should reject the client"); + assert!(!format!("{error:#}").contains("TLS support not compiled in")); + } #[tokio::test] async fn shutdown_waits_for_writer_before_reader() { diff --git a/ostool/src/run/ovmf_prebuilt/error.rs b/ostool/src/run/ovmf_prebuilt/error.rs index 54a69508..4e1510e0 100644 --- a/ostool/src/run/ovmf_prebuilt/error.rs +++ b/ostool/src/run/ovmf_prebuilt/error.rs @@ -46,7 +46,7 @@ pub enum Error { /// Remote request failed. #[error("remote request failed")] - Request(#[source] Box), + Request(#[source] reqwest::Error), /// Download failed. #[error("download failed")] diff --git a/ostool/src/run/ovmf_prebuilt/fetch.rs b/ostool/src/run/ovmf_prebuilt/fetch.rs index 48f564d2..f46e1e5d 100644 --- a/ostool/src/run/ovmf_prebuilt/fetch.rs +++ b/ostool/src/run/ovmf_prebuilt/fetch.rs @@ -1,12 +1,13 @@ use super::{Error, Source, error::MirrorAttempt}; use indicatif::{ProgressBar, ProgressStyle}; +use reqwest::{Client, header::RANGE}; use sha2::{Digest, Sha256}; use std::fs; -use std::io::{self, Cursor, ErrorKind, Read}; +use std::future::Future; +use std::io::{self, Cursor, ErrorKind}; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use tar::Archive; -use ureq::Agent; /// User-Agent header to send with download requests. const USER_AGENT: &str = "https://github.com/drivercraft/ostool"; @@ -73,11 +74,25 @@ struct VerifiedDownload { } /// Update the local cache. Does nothing if the cache is already up to date. -pub(crate) fn update_cache(source: Source, prebuilt_dir: &Path) -> Result<(), Error> { - update_cache_with_fetchers(source, prebuilt_dir, OVMF_MIRRORS, probe_url, download_url) +pub(crate) async fn update_cache(source: Source, prebuilt_dir: &Path) -> Result<(), Error> { + let probe_client = http_client(PROBE_TIMEOUT, PROBE_TIMEOUT, PROBE_TIMEOUT)?; + let download_client = http_client( + FULL_DOWNLOAD_TIMEOUT, + FULL_DOWNLOAD_CONNECT_TIMEOUT, + FULL_DOWNLOAD_BODY_TIMEOUT, + )?; + + update_cache_with_fetchers( + source, + prebuilt_dir, + OVMF_MIRRORS, + |url| probe_url(probe_client.clone(), url), + |url| download_url(download_client.clone(), url), + ) + .await } -fn update_cache_with_fetchers( +async fn update_cache_with_fetchers( source: Source, prebuilt_dir: &Path, mirrors: &[Mirror], @@ -85,8 +100,10 @@ fn update_cache_with_fetchers( download: D, ) -> Result<(), Error> where - P: FnMut(&str) -> Result, - D: FnMut(&str) -> Result, Error>, + P: FnMut(String) -> PF, + PF: Future>, + D: FnMut(String) -> DF, + DF: Future, Error>>, { let hash_path = prebuilt_dir.join("sha256"); @@ -104,8 +121,8 @@ where } } - let candidates = ranked_mirrors_by_probe(&source, mirrors, probe); - let verified = download_from_candidates(&source, &candidates, download)?; + let candidates = ranked_mirrors_by_probe(&source, mirrors, probe).await; + let verified = download_from_candidates(&source, &candidates, download).await?; // Clear out the existing prebuilt dir, if present. if let Err(source) = fs::remove_dir_all(prebuilt_dir) @@ -131,45 +148,42 @@ where Ok(()) } -fn ranked_mirrors_by_probe

( +async fn ranked_mirrors_by_probe( source: &Source, mirrors: &[Mirror], mut probe: P, ) -> Vec where - P: FnMut(&str) -> Result, + P: FnMut(String) -> PF, + PF: Future>, { - let mut candidates = mirrors - .iter() - .copied() - .enumerate() - .map(|(index, mirror)| { - let url = mirror.url(source); - let probe = match probe(&url) { - Ok(stats) => { - info!( - "OVMF mirror {} probe: {} bytes in {:?}", - mirror.name, stats.bytes, stats.elapsed - ); - Some(stats) - } - Err(err) => { - debug!( - "failed to probe OVMF mirror {} ({}): {}", - mirror.name, url, err - ); - None - } - }; - - MirrorCandidate { - mirror, - url, - probe, - index, + let mut candidates = Vec::with_capacity(mirrors.len()); + for (index, mirror) in mirrors.iter().copied().enumerate() { + let url = mirror.url(source); + let probe = match probe(url.clone()).await { + Ok(stats) => { + info!( + "OVMF mirror {} probe: {} bytes in {:?}", + mirror.name, stats.bytes, stats.elapsed + ); + Some(stats) } - }) - .collect::>(); + Err(err) => { + debug!( + "failed to probe OVMF mirror {} ({}): {}", + mirror.name, url, err + ); + None + } + }; + + candidates.push(MirrorCandidate { + mirror, + url, + probe, + index, + }); + } candidates.sort_by(|left_candidate, right_candidate| { match (left_candidate.probe, right_candidate.probe) { @@ -188,19 +202,23 @@ where candidates } -fn download_from_candidates( +async fn download_from_candidates( source: &Source, candidates: &[MirrorCandidate], mut download: D, ) -> Result where - D: FnMut(&str) -> Result, Error>, + D: FnMut(String) -> DF, + DF: Future, Error>>, { let mut attempts = Vec::new(); for candidate in candidates { info!("{}", download_source_message(candidate)); - match download(&candidate.url).and_then(|data| verify_download(source, data)) { + match download(candidate.url.clone()) + .await + .and_then(|data| verify_download(source, data)) + { Ok(verified) => return Ok(verified), Err(err) => { warn!( @@ -257,48 +275,47 @@ fn verify_download(source: &Source, data: Vec) -> Result Agent { - let config = Agent::config_builder() +) -> Result { + Client::builder() .user_agent(USER_AGENT) - .timeout_global(Some(global_timeout)) - .timeout_connect(Some(connect_timeout)) - .timeout_recv_response(Some(response_timeout)) - .timeout_recv_body(Some(body_timeout)) - .build(); - Agent::new_with_config(config) + .timeout(global_timeout) + .connect_timeout(connect_timeout) + .read_timeout(body_timeout) + .build() + .map_err(Error::Request) } -fn probe_url(url: &str) -> Result { - let agent = http_agent(PROBE_TIMEOUT, PROBE_TIMEOUT, PROBE_TIMEOUT, PROBE_TIMEOUT); - +async fn probe_url(client: Client, url: String) -> Result { info!("probing OVMF mirror {url}"); let started = Instant::now(); - let resp = agent - .get(url) - .header( - "Range", - format!("bytes=0-{}", PROBE_DOWNLOAD_SIZE_IN_BYTES - 1), - ) - .call() - .map_err(|err| Error::Request(Box::new(err)))?; - - let mut reader = resp - .into_body() - .into_reader() - .take(PROBE_DOWNLOAD_SIZE_IN_BYTES.try_into().unwrap()); - let mut buffer = [0u8; 8192]; + let mut response = tokio::time::timeout( + PROBE_TIMEOUT, + client + .get(url) + .header( + RANGE, + format!("bytes=0-{}", PROBE_DOWNLOAD_SIZE_IN_BYTES - 1), + ) + .send(), + ) + .await + .map_err(|_| timeout_error("OVMF mirror probe response"))? + .map_err(Error::Request)? + .error_for_status() + .map_err(Error::Request)?; + let mut bytes = 0usize; - loop { - match reader.read(&mut buffer) { - Ok(0) => break, - Ok(n) => bytes += n, - Err(e) => return Err(Error::Download(e)), - } + while bytes < PROBE_DOWNLOAD_SIZE_IN_BYTES { + let Some(chunk) = response.chunk().await.map_err(Error::Request)? else { + break; + }; + bytes += chunk + .len() + .min(PROBE_DOWNLOAD_SIZE_IN_BYTES.saturating_sub(bytes)); } if bytes == 0 { @@ -315,27 +332,22 @@ fn probe_url(url: &str) -> Result { } /// Download `url` and return the raw data. -fn download_url(url: &str) -> Result, Error> { - let agent = http_agent( - FULL_DOWNLOAD_TIMEOUT, - FULL_DOWNLOAD_CONNECT_TIMEOUT, - FULL_DOWNLOAD_RESPONSE_TIMEOUT, - FULL_DOWNLOAD_BODY_TIMEOUT, - ); - +async fn download_url(client: Client, url: String) -> Result, Error> { // Download the file. info!("downloading {url}"); - let resp = agent - .get(url) - .call() - .map_err(|err| Error::Request(Box::new(err)))?; + let mut response = + tokio::time::timeout(FULL_DOWNLOAD_RESPONSE_TIMEOUT, client.get(&url).send()) + .await + .map_err(|_| timeout_error("OVMF download response"))? + .map_err(Error::Request)? + .error_for_status() + .map_err(Error::Request)?; // Get content length if available - let content_length = resp - .headers() - .get("content-length") - .and_then(|s| s.to_str().ok()) - .and_then(|s| s.parse::().ok()); + let content_length = response.content_length(); + if content_length.is_some_and(|length| length > MAX_DOWNLOAD_SIZE_IN_BYTES as u64) { + return Err(download_size_error()); + } // Create progress bar let progress = if let Some(total) = content_length { @@ -365,27 +377,23 @@ fn download_url(url: &str) -> Result, Error> { pb }; - let mut data = Vec::with_capacity(MAX_DOWNLOAD_SIZE_IN_BYTES); - let mut reader = resp - .into_body() - .into_reader() - // Limit the size of the download. - .take(MAX_DOWNLOAD_SIZE_IN_BYTES.try_into().unwrap()); + let mut data = Vec::with_capacity( + content_length + .unwrap_or(MAX_DOWNLOAD_SIZE_IN_BYTES as u64) + .min(MAX_DOWNLOAD_SIZE_IN_BYTES as u64) as usize, + ); // Read in chunks and update progress - let mut buffer = [0u8; 8192]; - loop { - match reader.read(&mut buffer) { - Ok(0) => break, - Ok(n) => { - data.extend_from_slice(&buffer[..n]); - progress.inc(n as u64); - } - Err(e) => { - progress.finish_and_clear(); - return Err(Error::Download(e)); - } + while let Some(chunk) = response.chunk().await.map_err(|err| { + progress.finish_and_clear(); + Error::Request(err) + })? { + if data.len().saturating_add(chunk.len()) > MAX_DOWNLOAD_SIZE_IN_BYTES { + progress.finish_and_clear(); + return Err(download_size_error()); } + data.extend_from_slice(&chunk); + progress.inc(chunk.len() as u64); } progress.finish_with_message(format!("Downloaded {} bytes", data.len())); @@ -394,6 +402,20 @@ fn download_url(url: &str) -> Result, Error> { Ok(data) } +fn timeout_error(operation: &str) -> Error { + Error::Download(io::Error::new( + ErrorKind::TimedOut, + format!("{operation} timed out"), + )) +} + +fn download_size_error() -> Error { + Error::Download(io::Error::new( + ErrorKind::InvalidData, + format!("OVMF download exceeds the {MAX_DOWNLOAD_SIZE_IN_BYTES}-byte limit"), + )) +} + fn decompress(data: &[u8]) -> Result, Error> { info!("decompressing tarball"); let mut decompressed = Vec::new(); @@ -450,6 +472,7 @@ mod tests { use std::{ cell::RefCell, fs, + future::ready, io::{self, Cursor, ErrorKind}, time::Duration, }; @@ -511,8 +534,8 @@ mod tests { } } - #[test] - fn cache_hit_does_not_probe_or_download() { + #[tokio::test] + async fn cache_hit_does_not_probe_or_download() { let temp = TempDir::new().unwrap(); let source = test_source("test-release", "cached-hash".to_string()); fs::write(temp.path().join("sha256"), source.sha256).unwrap(); @@ -521,30 +544,34 @@ mod tests { source, temp.path(), TEST_MIRRORS, - |_| panic!("cache hit should not probe mirrors"), - |_| panic!("cache hit should not download mirrors"), + |_| async { panic!("cache hit should not probe mirrors") }, + |_| async { panic!("cache hit should not download mirrors") }, ) + .await .unwrap(); } - #[test] - fn ranked_mirrors_probe_every_source_and_prefer_fast_successes() { + #[tokio::test] + async fn ranked_mirrors_probe_every_source_and_prefer_fast_successes() { let source = test_source("test-release", "unused".to_string()); let probed = RefCell::new(Vec::new()); let ranked = ranked_mirrors_by_probe(&source, TEST_MIRRORS, |url| { - probed.borrow_mut().push(url.to_string()); - if url.contains("fast.example") { - Ok(probe(256 * 1024, 20)) - } else if url.contains("slow.example") { - Ok(probe(256 * 1024, 200)) - } else { - Err(Error::Download(io::Error::new( - ErrorKind::TimedOut, - "probe timed out", - ))) - } - }); + ready({ + probed.borrow_mut().push(url.to_string()); + if url.contains("fast.example") { + Ok(probe(256 * 1024, 20)) + } else if url.contains("slow.example") { + Ok(probe(256 * 1024, 200)) + } else { + Err(Error::Download(io::Error::new( + ErrorKind::TimedOut, + "probe timed out", + ))) + } + }) + }) + .await; assert_eq!(probed.borrow().len(), TEST_MIRRORS.len()); assert_eq!( @@ -556,8 +583,8 @@ mod tests { ); } - #[test] - fn fastest_download_failure_falls_back_to_slower_success() { + #[tokio::test] + async fn fastest_download_failure_falls_back_to_slower_success() { let temp = TempDir::new().unwrap(); let archive = test_tar_xz("test-release", "x64/code.fd", b"firmware"); let source = test_source("test-release", sha256_hex(&archive)); @@ -568,28 +595,33 @@ mod tests { temp.path(), TEST_MIRRORS, |url| { - if url.contains("fast.example") { - Ok(probe(256 * 1024, 10)) - } else if url.contains("slow.example") { - Ok(probe(256 * 1024, 100)) - } else { - Ok(probe(256 * 1024, 200)) - } + ready({ + if url.contains("fast.example") { + Ok(probe(256 * 1024, 10)) + } else if url.contains("slow.example") { + Ok(probe(256 * 1024, 100)) + } else { + Ok(probe(256 * 1024, 200)) + } + }) }, |url| { - downloads.borrow_mut().push(url.to_string()); - if url.contains("fast.example") { - Err(Error::Download(io::Error::new( - ErrorKind::ConnectionReset, - "fast mirror reset", - ))) - } else if url.contains("slow.example") { - Ok(archive.clone()) - } else { - panic!("backup mirror should not be downloaded after slow succeeds"); - } + ready({ + downloads.borrow_mut().push(url.to_string()); + if url.contains("fast.example") { + Err(Error::Download(io::Error::new( + ErrorKind::ConnectionReset, + "fast mirror reset", + ))) + } else if url.contains("slow.example") { + Ok(archive.clone()) + } else { + panic!("backup mirror should not be downloaded after slow succeeds"); + } + }) }, ) + .await .unwrap(); let downloads = downloads.borrow(); @@ -606,8 +638,8 @@ mod tests { ); } - #[test] - fn failed_mirrors_preserve_existing_cache_until_a_valid_download_is_ready() { + #[tokio::test] + async fn failed_mirrors_preserve_existing_cache_until_a_valid_download_is_ready() { let temp = TempDir::new().unwrap(); fs::create_dir_all(temp.path().join("x64")).unwrap(); fs::write(temp.path().join("x64/code.fd"), b"old firmware").unwrap(); @@ -620,15 +652,18 @@ mod tests { source, temp.path(), &TEST_MIRRORS[..2], - |_| Ok(probe(256 * 1024, 10)), + |_| ready(Ok(probe(256 * 1024, 10))), |url| { - if url.contains("fast.example") { - Ok(b"wrong hash".to_vec()) - } else { - Ok(b"not xz data".to_vec()) - } + ready({ + if url.contains("fast.example") { + Ok(b"wrong hash".to_vec()) + } else { + Ok(b"not xz data".to_vec()) + } + }) }, ) + .await .unwrap_err(); let Error::AllMirrorsFailed { attempts } = err else { @@ -647,8 +682,8 @@ mod tests { ); } - #[test] - fn all_download_failures_report_each_mirror() { + #[tokio::test] + async fn all_download_failures_report_each_mirror() { let temp = TempDir::new().unwrap(); let source = test_source("test-release", "expected-hash".to_string()); @@ -657,18 +692,23 @@ mod tests { temp.path(), &TEST_MIRRORS[..2], |_| { - Err(Error::Download(io::Error::new( - ErrorKind::TimedOut, - "probe", - ))) + ready({ + Err(Error::Download(io::Error::new( + ErrorKind::TimedOut, + "probe", + ))) + }) }, |url| { - Err(Error::Download(io::Error::new( - ErrorKind::TimedOut, - format!("download timed out at {url}"), - ))) + ready({ + Err(Error::Download(io::Error::new( + ErrorKind::TimedOut, + format!("download timed out at {url}"), + ))) + }) }, ) + .await .unwrap_err(); let Error::AllMirrorsFailed { attempts } = err else { diff --git a/ostool/src/run/ovmf_prebuilt/mod.rs b/ostool/src/run/ovmf_prebuilt/mod.rs index 3f164f15..1863b55a 100644 --- a/ostool/src/run/ovmf_prebuilt/mod.rs +++ b/ostool/src/run/ovmf_prebuilt/mod.rs @@ -89,10 +89,10 @@ impl Prebuilt { /// the release tarballs on Github. /// /// [`source.sha256`]: Source::sha256 - pub fn fetch>(source: Source, prebuilt_dir: P) -> Result { + pub async fn fetch>(source: Source, prebuilt_dir: P) -> Result { let prebuilt_dir = prebuilt_dir.as_ref(); - update_cache(source, prebuilt_dir)?; + update_cache(source, prebuilt_dir).await?; Ok(Self { dir: prebuilt_dir.to_owned(), diff --git a/ostool/src/run/qemu.rs b/ostool/src/run/qemu.rs index d9b4b83a..f68dc5a4 100644 --- a/ostool/src/run/qemu.rs +++ b/ostool/src/run/qemu.rs @@ -619,6 +619,7 @@ impl QemuRunner { println!("Preparing OVMF firmware for architecture: {arch:?}"); let prebuilt = Prebuilt::fetch(Source::LATEST, &bios_dir) + .await .with_context(|| format!("failed to prepare OVMF cache: {}", bios_dir.display()))?; let arch = match arch { Architecture::X86_64 => Arch::X64,