From e903c374558dc0c30f4a699f4f725a1f5df4fd39 Mon Sep 17 00:00:00 2001 From: ZCShou <72115@163.com> Date: Thu, 30 Jul 2026 14:06:58 +0800 Subject: [PATCH 1/5] feat(ostool): enable rustls native TLS roots for tokio-tungstenite --- Cargo.lock | 6 ++++++ ostool/Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 977f994..6473e17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4301,7 +4301,11 @@ checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite 0.28.0", ] @@ -4502,6 +4506,8 @@ dependencies = [ "httparse", "log", "rand 0.9.4", + "rustls", + "rustls-pki-types", "sha1 0.10.6", "thiserror 2.0.18", "utf-8", diff --git a/ostool/Cargo.toml b/ostool/Cargo.toml index b08ed8b..c97c223 100644 --- a/ostool/Cargo.toml +++ b/ostool/Cargo.toml @@ -62,7 +62,7 @@ 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] From 15e679278c3d78e98d94f019d3bb938d72f8aef1 Mon Sep 17 00:00:00 2001 From: ZCShou <72115@163.com> Date: Thu, 30 Jul 2026 15:25:17 +0800 Subject: [PATCH 2/5] feat(ostool): unify TLS provider via shared HTTP client builder Introduce a centralized http_client module that installs the Ring crypto provider before constructing any reqwest client, ensuring rustls is consistently configured across auth, board, and build pipelines. Switch reqwest and ureq dependencies to rustls-based TLS without the aws-lc-rs provider, and add the workspace rustls dependency to ostool. Update docs/api.md to cover the Management API surface. --- Cargo.lock | 224 +------------- Cargo.toml | 1 + docs/api.md | 471 +++++++++++++++++++++++++++-- ostool-server/Cargo.toml | 2 +- ostool/Cargo.toml | 12 +- ostool/src/auth/client.rs | 2 +- ostool/src/board/client.rs | 2 +- ostool/src/board/serial_stream.rs | 22 +- ostool/src/build/cargo_pipeline.rs | 2 +- ostool/src/http_client.rs | 11 + ostool/src/lib.rs | 1 + 11 files changed, 506 insertions(+), 244 deletions(-) create mode 100644 ostool/src/http_client.rs diff --git a/Cargo.lock b/Cargo.lock index 6473e17..7c33b08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -288,28 +288,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "aws-lc-rs" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "axum" version = "0.8.9" @@ -596,8 +574,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -677,15 +653,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "colorchoice" version = "1.0.5" @@ -1041,12 +1008,6 @@ dependencies = [ "litrs", ] -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "dyn-clone" version = "1.0.20" @@ -1297,12 +1258,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "funty" version = "2.0.0" @@ -1433,10 +1388,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -1446,11 +1399,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]] @@ -2036,16 +1987,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.102" @@ -2210,12 +2151,6 @@ dependencies = [ "hashbrown 0.17.1", ] -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "lzma-rs" version = "0.3.0" @@ -2620,6 +2555,7 @@ dependencies = [ "ratatui", "regex", "reqwest", + "rustls", "schemars", "serde", "serde_json", @@ -2956,62 +2892,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.4", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" version = "1.0.45" @@ -3288,7 +3168,6 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -3402,12 +3281,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - [[package]] name = "rustc_version" version = "0.4.1" @@ -3436,7 +3309,6 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ - "aws-lc-rs", "log", "once_cell", "ring", @@ -3464,7 +3336,6 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ - "web-time", "zeroize", ] @@ -3501,7 +3372,6 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -5111,16 +4981,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]] @@ -5138,31 +4999,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]] @@ -5171,96 +5015,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/Cargo.toml b/Cargo.toml index 9727869..16d6118 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,3 +36,4 @@ schemars = {version = "1.0.4", features = ["derive"]} env_logger = "0.11" tokio = {version = "1", features = ["full"]} +rustls = {version = "0.23", default-features = false, features = ["ring"]} diff --git a/docs/api.md b/docs/api.md index 28affc7..661a98f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,8 +1,13 @@ -# `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 契约提供受认证的开发板服务。 + +本文同时记录上述两类后端使用的接口契约,并不表示单个后端实现文中的全部接口。`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 的主机名不支持。 - `auth_mode = "required"` 时,`board.server` 必须使用 HTTPS,所有请求携带下文描述的 Bearer Token; - `auth_mode = "disabled"`(默认)时通常使用 HTTP,不会发送认证 Header,适合局域网直连 `ostool-server`。 @@ -39,7 +44,7 @@ HTTP 客户端不跟随重定向。认证模式下,绝对 WebSocket URL 必须 ## OAuth Device Authorization API -所有 OAuth 请求使用 `application/x-www-form-urlencoded`。 +本节接口只由认证模式下的独立认证后端提供,`ostool-server` 不实现这些路由。所有 OAuth 请求使用 `application/x-www-form-urlencoded`。 ### 基本原理 @@ -128,8 +133,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 +161,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": true, + "powered": false, + "last_action": "power_off", + "updated_at": "2026-07-30T06:00:00Z" +} +``` + +```json +{ + "lease_state": "idle", + "active_session_id": null, + "last_release_error": null, + "updated_at": "2026-07-30T06:00:00Z" +} +``` + +`powered`、`last_action` 和对应的 `updated_at` 在未知时为 `null`;`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 普通文件上传及会话查询/文件管理端点虽未由当前命令路径调用,仍是 `BoardServerClient` 或公开 board 服务契约的一部分。 ### 查询开发板类型 @@ -347,7 +743,7 @@ 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, @@ -356,6 +752,7 @@ GET /api/v1/sessions/{session_id}/tftp "filename": "Image", "relative_path": "boot/Image", "tftp_url": "tftp://192.168.1.2/boot/Image", + "http_url": "http://192.168.1.2:2999/share/sessions/.../boot/Image", "size": 1048576, "uploaded_at": "2026-07-20T02:00:00Z" } @@ -363,7 +760,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。 ### 获取和下载预置 DTB @@ -412,7 +809,7 @@ X-File-Path: ``` -`X-File-Path` 必填,必须是相对路径;请求体是文件原始字节。成功返回 `201 Created`: +`X-File-Path` 必填,必须是相对路径;请求体是文件原始字节。请求体大小受服务器配置 `upload_limits.session_file_max_mib` 限制,超限返回 `413 Payload Too Large`。成功返回 `201 Created`: ```json { @@ -425,8 +822,7 @@ X-File-Path: } ``` -`tftp_url` 可为 `null`;`http_url` 使用板端可访问的服务地址。会话文件 HTTP -共享不依赖 TFTP 是否启用。 +`tftp_url` 和 `http_url` 均可为 `null`;能够解析板端可访问的服务地址时,`http_url` 使用该地址。会话文件 HTTP 共享不依赖 TFTP 是否启用。 ### 列出、查询和删除会话文件 @@ -447,10 +843,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`。URL 仅在 session 活动期间有效,session 释放、超时或进入 releasing 状态后返回 `404`,对应文件随 session 清理。 ### 上传 HTTP Boot 文件 @@ -461,7 +854,7 @@ X-File-Path: ``` -`X-File-Path` 必填,请求体是文件原始字节。成功返回 `201 Created`: +`X-File-Path` 必填,请求体是文件原始字节。请求体大小受服务器配置 `upload_limits.session_file_max_mib` 限制,超限返回 `413 Payload Too Large`。成功返回 `201 Created`: ```json { @@ -494,7 +887,7 @@ X-HttpBoot-Entry-Symbol: # 可选 ``` -请求体是内核原始字节。成功返回 `201 Created`: +请求体是内核原始字节,大小受服务器配置 `upload_limits.session_file_max_mib` 限制,超限返回 `413 Payload Too Large`。成功返回 `201 Created`: ```json { @@ -509,7 +902,7 @@ X-HttpBoot-Entry-Symbol: # 可选 ## 串口 WebSocket API -会话创建或串口状态响应中的 `ws_url` 用于建立串口连接。相对地址相对于 Base URL 解析;HTTP/HTTPS Base URL 会分别转换为 `ws`/`wss`。 +会话创建或串口状态响应中的 `ws_url` 用于建立串口连接。客户端先将 HTTP/HTTPS Base URL 的 scheme 分别转换为 `ws`/`wss`,再解析 `ws_url`:完整的 `ws://` 或 `wss://` 地址直接使用;以 `/` 开头的地址从当前 origin 的根路径解析,会丢弃 Base URL 中的路径前缀;不以 `/` 开头的相对地址追加到 Base URL 路径。认证模式下,服务端返回的完整 WebSocket URL 必须与 Base URL 使用相同的 scheme、host 和有效端口。 ```http GET /api/v1/sessions/{session_id}/serial/ws @@ -517,15 +910,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 { @@ -535,4 +958,6 @@ WebSocket 二进制帧承载串口字节流;客户端关闭时会发送: } ``` -`details` 是可选 JSON 值,当前服务端通常返回 `null`;客户端只使用 `code` 和 `message`。任一 board API 返回 `401 Unauthorized` 时,客户端删除当前 endpoint 的本地凭据;不会自动刷新并重试该业务请求。 +`details` 是可选 JSON 值,当前服务端通常返回 `null`。常见 `code` 包括 `bad_request`、`payload_too_large`、`not_found`、`conflict`、`service_unavailable` 和 `internal_server_error`。请求在进入业务处理器前由 Axum 拒绝时不保证使用此结构,例如无法反序列化 JSON 请求体时可能直接返回 `422 Unprocessable Entity`;WebSocket Upgrade 提取失败时也使用框架自身的错误响应。 + +`ostool` 客户端只使用 Board API 错误中的 `code` 和 `message`。任一 Board API 返回 `401 Unauthorized` 时,客户端删除当前 endpoint 的本地凭据;不会自动刷新并重试该业务请求。Management API 本身当前不会生成认证类错误,反向代理增加的认证错误也不保证使用上述 JSON 格式。 diff --git a/ostool-server/Cargo.toml b/ostool-server/Cargo.toml index 330e1ef..067808f 100644 --- a/ostool-server/Cargo.toml +++ b/ostool-server/Cargo.toml @@ -53,7 +53,7 @@ url = {workspace = true} uuid = {version = "1", features = ["serde", "v4"]} [dev-dependencies] -reqwest = {version = "0.13", features = ["json"]} +reqwest = {version = "0.13", default-features = false, 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/Cargo.toml b/ostool/Cargo.toml index c97c223..26631fc 100644 --- a/ostool/Cargo.toml +++ b/ostool/Cargo.toml @@ -41,7 +41,15 @@ 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-no-provider", + "system-proxy", +] } +rustls = { workspace = true } schemars = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } @@ -58,7 +66,7 @@ regex = "1" sha2 = "0.11" tar = "0.4" thiserror = { workspace = true } -ureq = "3.0" +ureq = { version = "3.0", default-features = false, features = ["gzip", "rustls"] } chrono = { version = "0.4.44", features = ["serde"] } fs4 = "1.1" keyring = "4.1" diff --git a/ostool/src/auth/client.rs b/ostool/src/auth/client.rs index 8b13639..31f50b0 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: crate::http_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/client.rs b/ostool/src/board/client.rs index 1181b84..4509dc0 100644 --- a/ostool/src/board/client.rs +++ b/ostool/src/board/client.rs @@ -229,7 +229,7 @@ impl BoardServerClient { pub fn new_with_endpoint(endpoint: BoardEndpoint) -> anyhow::Result { Ok(Self { - client: reqwest::Client::builder() + client: crate::http_client::builder() .no_proxy() .redirect(reqwest::redirect::Policy::none()) .build() diff --git a/ostool/src/board/serial_stream.rs b/ostool/src/board/serial_stream.rs index b18daaf..1c95a58 100644 --- a/ostool/src/board/serial_stream.rs +++ b/ostool/src/board/serial_stream.rs @@ -245,7 +245,27 @@ 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_connection_uses_configured_tls() { + 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")); + assert!(rustls::crypto::CryptoProvider::get_default().is_some()); + } #[tokio::test] async fn shutdown_waits_for_writer_before_reader() { diff --git a/ostool/src/build/cargo_pipeline.rs b/ostool/src/build/cargo_pipeline.rs index 1b2f4a5..35b4df3 100644 --- a/ostool/src/build/cargo_pipeline.rs +++ b/ostool/src/build/cargo_pipeline.rs @@ -569,7 +569,7 @@ impl<'a> CargoBuildPipeline<'a> { let target_path = temp_dir.join(filename); // Create reqwest client - let client = reqwest::Client::builder() + let client = crate::http_client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?; diff --git a/ostool/src/http_client.rs b/ostool/src/http_client.rs new file mode 100644 index 0000000..50126f7 --- /dev/null +++ b/ostool/src/http_client.rs @@ -0,0 +1,11 @@ +//! Centralizes process-wide TLS provider selection and HTTP client construction. +//! +//! Although this module currently exposes only one small function, keeping it separate prevents +//! transport setup from being coupled to authentication, board, or build logic. All reqwest clients +//! should be created here so Ring is installed before Rustls is used and future transport changes +//! remain confined to one place. + +pub(crate) fn builder() -> reqwest::ClientBuilder { + let _ = rustls::crypto::ring::default_provider().install_default(); + reqwest::Client::builder() +} diff --git a/ostool/src/lib.rs b/ostool/src/lib.rs index 5ae8e42..1543bb0 100644 --- a/ostool/src/lib.rs +++ b/ostool/src/lib.rs @@ -34,6 +34,7 @@ mod artifact; mod boot; +mod http_client; /// Authentication gateway client, secure credential storage, and token lifecycle management. pub mod auth; From 5baf4ce84796cb043720afcbaf26c9aa7f92914b Mon Sep 17 00:00:00 2001 From: ZCShou <72115@163.com> Date: Thu, 30 Jul 2026 16:24:07 +0800 Subject: [PATCH 3/5] test(ostool): replace reqwest with in-process axum router in tests --- Cargo.lock | 1 - ostool-server/Cargo.toml | 1 - ostool-server/tests/session_ws_lifecycle.rs | 125 +++++++++++--------- 3 files changed, 70 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7c33b08..dd88494 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2593,7 +2593,6 @@ dependencies = [ "mime_guess", "network-interface", "nusb", - "reqwest", "rust-embed", "schemars", "serde", diff --git a/ostool-server/Cargo.toml b/ostool-server/Cargo.toml index 067808f..503a1b9 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", default-features = false, 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 03f0f40..e6ee44b 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() +// Keep REST setup and assertions in-process. A reqwest dev-dependency would inherit ostool's +// rustls-no-provider feature during workspace builds and make this LAN-only test require TLS setup. +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" { From c7b4827cedda2c42f7d6de3e44f18c32e8446d57 Mon Sep 17 00:00:00 2001 From: ZCShou <72115@163.com> Date: Thu, 30 Jul 2026 17:15:29 +0800 Subject: [PATCH 4/5] refactor(ostool): replace ureq with async reqwest and remove custom http_client --- Cargo.lock | 219 +++++++++--- Cargo.toml | 1 - ostool-server/tests/session_ws_lifecycle.rs | 4 +- ostool/Cargo.toml | 4 +- ostool/src/auth/client.rs | 2 +- ostool/src/board/client.rs | 2 +- ostool/src/board/serial_stream.rs | 3 +- ostool/src/build/cargo_pipeline.rs | 2 +- ostool/src/http_client.rs | 11 - ostool/src/lib.rs | 1 - ostool/src/run/ovmf_prebuilt/error.rs | 2 +- ostool/src/run/ovmf_prebuilt/fetch.rs | 376 +++++++++++--------- ostool/src/run/ovmf_prebuilt/mod.rs | 4 +- ostool/src/run/qemu.rs | 1 + 14 files changed, 390 insertions(+), 242 deletions(-) delete mode 100644 ostool/src/http_client.rs diff --git a/Cargo.lock b/Cargo.lock index dd88494..b9d6847 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -288,6 +288,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.8.9" @@ -574,6 +597,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -589,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" @@ -653,6 +689,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -1008,6 +1053,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -1258,6 +1309,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "funty" version = "2.0.0" @@ -1388,8 +1445,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1411,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]] @@ -1987,6 +2049,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.2", + "libc", +] + [[package]] name = "js-sys" version = "0.3.102" @@ -2151,6 +2223,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lzma-rs" version = "0.3.0" @@ -2555,7 +2633,6 @@ dependencies = [ "ratatui", "regex", "reqwest", - "rustls", "schemars", "serde", "serde_json", @@ -2572,7 +2649,6 @@ dependencies = [ "toml", "trybuild", "uboot-shell", - "ureq", "url", ] @@ -2891,6 +2967,63 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -2939,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" @@ -2977,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" @@ -3167,6 +3326,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -3280,6 +3440,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3308,9 +3474,8 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ - "log", + "aws-lc-rs", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3335,6 +3500,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -3371,6 +3537,7 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4506,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" @@ -4560,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" @@ -4779,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" diff --git a/Cargo.toml b/Cargo.toml index 16d6118..9727869 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,4 +36,3 @@ schemars = {version = "1.0.4", features = ["derive"]} env_logger = "0.11" tokio = {version = "1", features = ["full"]} -rustls = {version = "0.23", default-features = false, features = ["ring"]} diff --git a/ostool-server/tests/session_ws_lifecycle.rs b/ostool-server/tests/session_ws_lifecycle.rs index e6ee44b..b67c4cd 100644 --- a/ostool-server/tests/session_ws_lifecycle.rs +++ b/ostool-server/tests/session_ws_lifecycle.rs @@ -415,8 +415,8 @@ async fn wait_for_server_ready(base_url: &str) -> Result<()> { } } -// Keep REST setup and assertions in-process. A reqwest dev-dependency would inherit ostool's -// rustls-no-provider feature during workspace builds and make this LAN-only test require TLS setup. +// 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, diff --git a/ostool/Cargo.toml b/ostool/Cargo.toml index 26631fc..9dc485d 100644 --- a/ostool/Cargo.toml +++ b/ostool/Cargo.toml @@ -46,10 +46,9 @@ reqwest = { version = "0.13", default-features = false, features = [ "form", "http2", "json", - "rustls-no-provider", + "rustls", "system-proxy", ] } -rustls = { workspace = true } schemars = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } @@ -66,7 +65,6 @@ regex = "1" sha2 = "0.11" tar = "0.4" thiserror = { workspace = true } -ureq = { version = "3.0", default-features = false, features = ["gzip", "rustls"] } chrono = { version = "0.4.44", features = ["serde"] } fs4 = "1.1" keyring = "4.1" diff --git a/ostool/src/auth/client.rs b/ostool/src/auth/client.rs index 31f50b0..9316418 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: crate::http_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/client.rs b/ostool/src/board/client.rs index 4509dc0..1181b84 100644 --- a/ostool/src/board/client.rs +++ b/ostool/src/board/client.rs @@ -229,7 +229,7 @@ impl BoardServerClient { pub fn new_with_endpoint(endpoint: BoardEndpoint) -> anyhow::Result { Ok(Self { - client: crate::http_client::builder() + client: reqwest::Client::builder() .no_proxy() .redirect(reqwest::redirect::Policy::none()) .build() diff --git a/ostool/src/board/serial_stream.rs b/ostool/src/board/serial_stream.rs index 1c95a58..441c09b 100644 --- a/ostool/src/board/serial_stream.rs +++ b/ostool/src/board/serial_stream.rs @@ -248,7 +248,7 @@ mod tests { use super::{SerialStreamTasks, connect_serial_stream, websocket_request, write_bridge_bytes}; #[tokio::test] - async fn secure_websocket_connection_uses_configured_tls() { + 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 { @@ -264,7 +264,6 @@ mod tests { .err() .expect("dummy TLS server should reject the client"); assert!(!format!("{error:#}").contains("TLS support not compiled in")); - assert!(rustls::crypto::CryptoProvider::get_default().is_some()); } #[tokio::test] diff --git a/ostool/src/build/cargo_pipeline.rs b/ostool/src/build/cargo_pipeline.rs index 35b4df3..1b2f4a5 100644 --- a/ostool/src/build/cargo_pipeline.rs +++ b/ostool/src/build/cargo_pipeline.rs @@ -569,7 +569,7 @@ impl<'a> CargoBuildPipeline<'a> { let target_path = temp_dir.join(filename); // Create reqwest client - let client = crate::http_client::builder() + let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?; diff --git a/ostool/src/http_client.rs b/ostool/src/http_client.rs deleted file mode 100644 index 50126f7..0000000 --- a/ostool/src/http_client.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Centralizes process-wide TLS provider selection and HTTP client construction. -//! -//! Although this module currently exposes only one small function, keeping it separate prevents -//! transport setup from being coupled to authentication, board, or build logic. All reqwest clients -//! should be created here so Ring is installed before Rustls is used and future transport changes -//! remain confined to one place. - -pub(crate) fn builder() -> reqwest::ClientBuilder { - let _ = rustls::crypto::ring::default_provider().install_default(); - reqwest::Client::builder() -} diff --git a/ostool/src/lib.rs b/ostool/src/lib.rs index 1543bb0..5ae8e42 100644 --- a/ostool/src/lib.rs +++ b/ostool/src/lib.rs @@ -34,7 +34,6 @@ mod artifact; mod boot; -mod http_client; /// Authentication gateway client, secure credential storage, and token lifecycle management. pub mod auth; diff --git a/ostool/src/run/ovmf_prebuilt/error.rs b/ostool/src/run/ovmf_prebuilt/error.rs index 54a6950..4e1510e 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 48f564d..f46e1e5 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 3f164f1..1863b55 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 d9b4b83..f68dc5a 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, From f53fd75d92bda0162431dc67b4bfe15d12ac1d46 Mon Sep 17 00:00:00 2001 From: ZCShou <72115@163.com> Date: Fri, 31 Jul 2026 11:06:40 +0800 Subject: [PATCH 5/5] docs(ostool): clarify base URL, session lifecycle and file path semantics --- docs/api.md | 104 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 61 insertions(+), 43 deletions(-) diff --git a/docs/api.md b/docs/api.md index 661a98f..c09e9b6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -7,9 +7,11 @@ 本文同时记录上述两类后端使用的接口契约,并不表示单个后端实现文中的全部接口。`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 的主机名不支持。 +服务地址来自全局或项目配置中的 `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 的主机名不支持。 -- `auth_mode = "required"` 时,`board.server` 必须使用 HTTPS,所有请求携带下文描述的 Bearer Token; +客户端会先把 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`。 ## 通用认证规则 @@ -26,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 和有效端口。 ## 命令索引 @@ -297,7 +299,7 @@ Content-Type: application/json ``` - `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 文件名。 +- 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`: @@ -318,10 +320,10 @@ GET /api/v1/admin/boards/{board_id}/runtime-status ```json { - "available": true, - "powered": false, - "last_action": "power_off", - "updated_at": "2026-07-30T06:00:00Z" + "available": false, + "powered": null, + "last_action": null, + "updated_at": null } ``` @@ -334,7 +336,7 @@ GET /api/v1/admin/boards/{board_id}/runtime-status } ``` -`powered`、`last_action` 和对应的 `updated_at` 在未知时为 `null`;`last_action` 可为 `power_on` 或 `power_off`。`lease_state` 可为 `idle`、`using`、`releasing` 或 `error`。 +当前 `ostool-server` 只用该接口确认开发板是否存在,尚未维护可查询的实时电源状态,因此对存在的开发板固定返回 `available: false`,其余三个字段为 `null`。响应模型为后续电源状态后端预留了 `powered`、`last_action` 和 `updated_at`;实现这些字段后,`last_action` 可为 `power_on` 或 `power_off`。`lease_state` 可为 `idle`、`using`、`releasing` 或 `error`。 硬件发现接口读取服务器当前可见的串口和网络接口: @@ -555,7 +557,7 @@ Content-Type: application/json ## Board REST API -本节定义两种后端共用的开发板服务契约:本地局域网模式由 `ostool-server` 直接提供,认证模式由独立认证后端提供受认证的对应接口。这里覆盖 `ostool-server` 的全部公开、非管理 REST 接口;`ostool` 当前命令直接调用其中的大部分接口,电源控制、HTTP Boot 普通文件上传及会话查询/文件管理端点虽未由当前命令路径调用,仍是 `BoardServerClient` 或公开 board 服务契约的一部分。 +本节定义两种后端共用的开发板服务契约:本地局域网模式由 `ostool-server` 直接提供,认证模式由独立认证后端提供受认证的对应接口。这里覆盖 `ostool-server` 的全部公开、非管理 REST 接口。`ostool` 当前命令会使用会话文件上传,但不会直接调用会话详情、会话文件列表/查询/删除、显式电源控制和普通 HTTP Boot 文件上传;后者仍属于公开 board 服务契约,其中显式电源控制和普通 HTTP Boot 文件上传也已有 `BoardServerClient` 方法。 ### 查询开发板类型 @@ -599,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` 会直接返回给调用者。 ### 查询会话详情 @@ -624,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" }, @@ -669,16 +677,16 @@ 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" } ``` -删除成功时服务端返回 `202 Accepted` 且没有响应体;删除时返回 `404`,客户端也将其视为已释放。 +删除成功时服务端返回 `202 Accepted` 且没有响应体;会话不存在时返回 `404`,客户端也将其视为已释放。 ### 获取启动配置 @@ -710,7 +718,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` 使用板端可访问的网络地址,不一定等于管理网地址。 ### 获取串口状态 @@ -726,11 +734,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 状态 @@ -750,8 +758,8 @@ GET /api/v1/sessions/{session_id}/tftp "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" @@ -760,7 +768,7 @@ GET /api/v1/sessions/{session_id}/tftp } ``` -`provider` 可为 `builtin` 或 `system_tftpd_hpa`。`server_ip`、`netmask` 以及每个文件的 `tftp_url` 和 `http_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 @@ -774,13 +782,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`。 ### 开关机 @@ -809,20 +817,20 @@ X-File-Path: ``` -`X-File-Path` 必填,必须是相对路径;请求体是文件原始字节。请求体大小受服务器配置 `upload_limits.session_file_max_mib` 限制,超限返回 `413 Payload Too Large`。成功返回 `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` 和 `http_url` 均可为 `null`;能够解析板端可访问的服务地址时,`http_url` 使用该地址。会话文件 HTTP 共享不依赖 TFTP 是否启用。 +响应中的 `relative_path` 是包含 `ostool/sessions/{session_id}/` 的 TFTP 根目录相对路径;上传和后续查询、删除时仍使用会话内路径 `boot/Image`。`tftp_url` 和 `http_url` 均可为 `null`;能够解析板端可访问的服务地址时,`http_url` 使用该地址。会话文件 HTTP 共享不依赖 TFTP 是否启用。 ### 列出、查询和删除会话文件 @@ -832,7 +840,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 的形式。 @@ -843,7 +851,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 文件 @@ -854,18 +862,20 @@ X-File-Path: ``` -`X-File-Path` 必填,请求体是文件原始字节。请求体大小受服务器配置 `upload_limits.session_file_max_mib` 限制,超限返回 `413 Payload Too Large`。成功返回 `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 @@ -873,21 +883,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: # 可选 ``` -请求体是内核原始字节,大小受服务器配置 `upload_limits.session_file_max_mib` 限制,超限返回 `413 Payload Too Large`。成功返回 `201 Created`: +该接口与普通 HTTP Boot 文件上传具有相同的会话类型和服务开关限制。`X-HttpBoot-Remote-Name` 也必须是合法的会话内相对路径。请求体是内核原始字节,大小受服务器配置 `upload_limits.session_file_max_mib` 限制,超限返回 `413 Payload Too Large`。成功返回 `201 Created`: ```json { @@ -898,11 +908,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` 用于建立串口连接。客户端先将 HTTP/HTTPS Base URL 的 scheme 分别转换为 `ws`/`wss`,再解析 `ws_url`:完整的 `ws://` 或 `wss://` 地址直接使用;以 `/` 开头的地址从当前 origin 的根路径解析,会丢弃 Base URL 中的路径前缀;不以 `/` 开头的相对地址追加到 Base URL 路径。认证模式下,服务端返回的完整 WebSocket URL 必须与 Base URL 使用相同的 scheme、host 和有效端口。 +会话创建或串口状态响应中的 `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 @@ -958,6 +976,6 @@ Board REST 和 Management API 业务处理器产生的错误使用以下格式 } ``` -`details` 是可选 JSON 值,当前服务端通常返回 `null`。常见 `code` 包括 `bad_request`、`payload_too_large`、`not_found`、`conflict`、`service_unavailable` 和 `internal_server_error`。请求在进入业务处理器前由 Axum 拒绝时不保证使用此结构,例如无法反序列化 JSON 请求体时可能直接返回 `422 Unprocessable Entity`;WebSocket Upgrade 提取失败时也使用框架自身的错误响应。 +`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 API 错误中的 `code` 和 `message`。任一 Board API 返回 `401 Unauthorized` 时,客户端删除当前 endpoint 的本地凭据;不会自动刷新并重试该业务请求。Management API 本身当前不会生成认证类错误,反向代理增加的认证错误也不保证使用上述 JSON 格式。 +`ostool` 客户端只使用 Board REST API 错误中的 `code` 和 `message`。任一 Board REST 请求返回 `401 Unauthorized` 时,客户端删除当前 endpoint 的本地凭据;不会自动刷新并重试该业务请求。WebSocket 握手返回 `401` 时当前不会触发该凭据清理逻辑。Management API 本身当前不会生成认证类错误,反向代理增加的认证错误也不保证使用上述 JSON 格式。