Compare commits

..

8 Commits
v261001 ... dev

Author SHA1 Message Date
mofeng-git
3a881ed0f7 docs: 更新 README.md 2026-10-13 22:31:56 +08:00
mofeng-git
d56665371f docs: 移除文档 2026-10-13 22:30:26 +08:00
mofeng-git
d7239ba15c fix(otg): 适配 OpenWrt netifd 网络桥接与事务恢复 2026-10-13 22:15:14 +08:00
mofeng-git
2b53c2982b fix(watchdog): 修复 musl 下 ioctl 请求参数类型不匹配 2026-10-13 22:11:55 +08:00
mofeng-git
1d40b34f1c feat: 支持从 GitHub Release 生成在线升级站点 2026-10-13 22:05:19 +08:00
mofeng-git
f926692e2c fix(web): 对齐侧边栏移动端判断与响应式断点 2026-10-13 21:56:21 +08:00
mofeng-git
2ebad622f0 feat: 增加 OpenWrt 系统管理入口
- 通过 ONE_KVM_OPENWRT=1 控制入口显示和跳转;
- 读取 uhttpd 当前监听配置,并跳转到 LuCI 地址。
2026-10-12 23:02:12 +08:00
mofeng-git
92f4203bc2 build: 启用显式 RKMPP 构建 2026-10-12 22:53:12 +08:00
31 changed files with 1720 additions and 115 deletions

View File

@@ -11,6 +11,7 @@ categories = ["embedded", "network-programming"]
[features]
default = ["desktop"]
rkmpp = ["hwcodec/rkmpp"]
desktop = [
"dep:tokio",
"dep:tokio-util",

View File

@@ -38,16 +38,23 @@ For more information, see the [One-KVM Rust documentation](https://docs.one-kvm.
| Area | Capabilities |
|------|----------------|
| Video capture | HDMI USB / MIPI CSI / RK3588 HDMI IN; MJPEG and WebRTC (H.264 / H.265 / VP8 / VP9) |
| Video encoding | VAAPI / QSV / RKMPP / V4L2 M2M hardware paths, with software fallback |
| Keyboard & mouse | USB OTG HID or CH340 + CH9329 HID; absolute / relative mouse |
| Virtual media | USB mass storage; ISO/IMG mount and Ventoy-style virtual USB |
| ATX power | GPIO or USB relay; power control |
| Audio | ALSA capture + Opus (HTTP / WebRTC) |
| Video capture & streaming | USB HDMI capture cards, MIPI CSI, and RK3588 HDMI IN; MJPEG / WebRTC (H.264 / H.265 / VP8 / VP9) video streams |
| Video encoding | VAAPI / QSV / NVENC / AMF / RKMPP / V4L2 M2M hardware encoding, with software fallback |
| Keyboard & mouse | USB OTG HID, CH340 + CH9329 serial HID, and Bluetooth HID; absolute / relative mouse over USB or serial, relative mouse over Bluetooth |
| Virtual media | Mount ISO / IMG images through USB mass storage; virtual CD-ROM, virtual USB drive, and Ventoy multi-image boot |
| ATX power | GPIO, USB HID / serial relays for power on/off, forced shutdown, and reset; GPIO can sense power and disk activity LEDs |
| Wake-on-LAN | WOL with network interface selection and wake history |
| Audio streaming | Linux ALSA / Windows WASAPI capture with Opus encoding; stream audio from the controlled computer over WebSocket / WebRTC |
| USB microphone | Forward browser microphone audio to the controlled computer through USB OTG UAC |
| USB network sharing | NCM / ECM / RNDIS virtual adapters bridge the controlled computer to a wired network over USB |
| Remote client access | Built-in RustDesk and VNC services; RustDesk ID server / direct IP modes and VNC Tight JPEG / H.264 encoding |
| RTSP video | H.264 / H.265 RTSP streams with username and password authentication |
Feature availability depends on the platform, hardware, and drivers.
## Installation
Release artifacts are on [GitHub Releases](https://github.com/mofeng-git/One-KVM/releases). Below are short paths for common setups. For **system requirements, hardware, Docker env vars, USB OTG**, and full troubleshooting, see the [One-KVM documentation](https://docs.one-kvm.cn/) (Chinese; use a translator if needed).
Release artifacts are on [GitHub Releases](https://github.com/mofeng-git/One-KVM/releases). Below are brief steps for common installation methods. For full details on **system requirements, hardware preparation, Docker environment variables, and USB OTG**, see the [One-KVM Rust documentation](https://docs.one-kvm.cn/).
### Debian / Ubuntu (.deb)
@@ -102,7 +109,7 @@ One-KVM builds on many great open-source projects; a lot of time goes into testi
### Thanks
<details>
<summary><strong>Supporter list</strong></summary>
<summary><strong>Click to view the supporter list</strong></summary>
- 浩龙的电子嵌入式之路
@@ -226,12 +233,108 @@ One-KVM builds on many great open-source projects; a lot of time goes into testi
- 故人。
- WindowsHao
- 爱发电用户_d4f8b
- 爱发电用户_59f5a
- 爱发电用户_96f28
- 孤星赏月
- t316161070
- 爱发电用户_f5gW
- 爱发电用户_5fb5c
- 爱发电用户_86d54
- 爱发电用户_maRx
- nb
- 爱发电用户_0aa4a
- 爱发电用户_f75a7
- 爱发电用户_b4646
- n1k0
- Yeskf
- 魔力回旋彪
- 泡面
- 中看台的杰森
- LWF
- 爱发电用户_b38fb
- 丨拒绝啦丶
- 爱发电用户_rGYn
- 爱发电用户_XU9y
- 爱发电用户_ec19b
- 爱发电用户_A4dE
- 爱发电用户_eYRC
- Aleo
- 淼
- 爱发电用户_5d06d
- Ghost_chu
- 爱发电用户_1fa21
- 爱发电用户_a7072
- 爱发电用户_9HrC
- 飒歼外寒
- Resucdueff
- Gentisan
- 爱发电用户_MGAx
- 爱发电用户_bce93
- 爱发电用户_60f87
- Bottling3748
- 爱发电用户_7cbeb
- 爱发电用户_6c721
- 哦哦哦哦
- 盒子先生
- 似水流年
- chnxan
- ......
</details>
### Sponsors
This project is supported by the following sponsors:
**File hosting**
- **[Huang1111 public-interest program](https://pan.huang1111.cn/s/mxkx3T1)** — login-free downloads
@@ -240,12 +343,12 @@ One-KVM builds on many great open-source projects; a lot of time goes into testi
- **[林枫云](https://www.dkdun.cn)** — project server sponsorship
![林枫云](https://docs.one-kvm.cn/img/36076FEFF0898A80EBD5756D28F4076C.png)
<img height="128" alt="林枫云" src="https://docs.one-kvm.cn/img/36076FEFF0898A80EBD5756D28F4076C.png" />
林枫云 offers premium network routes, high-frequency game servers, and high-bandwidth servers in China and abroad.
- **[Beta Network](https://my.beita.cc/?ref=github_onekvm)** — project server sponsorship
![BTBT](https://github.com/user-attachments/assets/c442d5f5-d72f-4a07-b9f4-400a6a0c3f1e)
<img height="128" alt="BTBT" src="https://github.com/user-attachments/assets/c442d5f5-d72f-4a07-b9f4-400a6a0c3f1e" />
Remote computers, consumer GPU servers, and dedicated physical machines with fully automated online delivery.

115
README.md
View File

@@ -37,13 +37,20 @@
**核心功能**
| 功能 | 能力说明 |
|------|------|
| 视频采集 | HDMI USB /MIPI CSI/RK3588 HDMI IN 采集支持,提供 MJPEG / WebRTC(H.264/H.265/VP8/VP9) 视频流|
| 视频编码 | VAAPI/QSV/RKMPP/V4L2M2M 硬件编码支持,以及软件编码兜底 |
| 键鼠控制 | USB OTG HID 或 CH340 + CH9329 HID,支持绝对/相对鼠标模式 |
| 虚拟媒体 | USB Mass Storage,支持 ISO/IMG 镜像挂载和 Ventoy 虚拟U盘模式 |
| ATX 电源控制 | GPIO/USB 继电器,支持电源控制 |
| 音频传输 | ALSA 采集 + Opus 编码(HTTP/WebRTC) |
|------|----------|
| 视频采集与传输 | 支持 USB HDMI 采集卡、MIPI CSI 和 RK3588 HDMI IN,提供 MJPEG / WebRTC(H.264 / H.265 / VP8 / VP9)视频流 |
| 视频编码 | 支持 VAAPI / QSV / NVENC / AMF / RKMPP / V4L2 M2M 硬件编码,以及软件编码兜底 |
| 键鼠控制 | 支持 USB OTG HID、CH340 + CH9329 串口 HID 和蓝牙 HID;USB / 串口支持绝对与相对鼠标模式,蓝牙支持相对鼠标模式 |
| 虚拟媒体 | 通过 USB Mass Storage 挂载 ISO / IMG 镜像,支持虚拟光驱、虚拟 U 盘和 Ventoy 多镜像启动 |
| ATX 电源控制 | 支持 GPIO、USB HID / 串口继电器,实现开关机、强制关机和重启;GPIO 可检测电源与硬盘活动指示灯 |
| 网络唤醒 | 支持 Wake-on-LAN(WOL),可选择发送网卡并保存唤醒历史 |
| 音频传输 | 支持 Linux ALSA / Windows WASAPI 采集与 Opus 编码,通过 WebSocket / WebRTC 传输被控机音频 |
| USB 麦克风 | 通过 USB OTG UAC 将浏览器麦克风音频传入被控机 |
| USB 网络共享 | 支持 NCM / ECM / RNDIS 虚拟网卡,将被控机通过 USB 桥接到有线网络 |
| 远程客户端接入 | 内置 RustDesk 与 VNC 服务;RustDesk 支持 ID 服务与 IP 直连,VNC 支持 Tight JPEG / H.264 |
| RTSP 视频流 | 提供 H.264 / H.265 RTSP 视频流,支持用户名与密码认证 |
具体功能可用性取决于运行平台、硬件与驱动。
## 安装使用
@@ -220,6 +227,100 @@ One-KVM 已上架飞牛 **应用市场**,在 NAS 上直接搜索安装即可
- 故人。
- WindowsHao
- 爱发电用户_d4f8b
- 爱发电用户_59f5a
- 爱发电用户_96f28
- 孤星赏月
- t316161070
- 爱发电用户_f5gW
- 爱发电用户_5fb5c
- 爱发电用户_86d54
- 爱发电用户_maRx
- nb
- 爱发电用户_0aa4a
- 爱发电用户_f75a7
- 爱发电用户_b4646
- n1k0
- Yeskf
- 魔力回旋彪
- 泡面
- 中看台的杰森
- LWF
- 爱发电用户_b38fb
- 丨拒绝啦丶
- 爱发电用户_rGYn
- 爱发电用户_XU9y
- 爱发电用户_ec19b
- 爱发电用户_A4dE
- 爱发电用户_eYRC
- Aleo
- 淼
- 爱发电用户_5d06d
- Ghost_chu
- 爱发电用户_1fa21
- 爱发电用户_a7072
- 爱发电用户_9HrC
- 飒歼外寒
- Resucdueff
- Gentisan
- 爱发电用户_MGAx
- 爱发电用户_bce93
- 爱发电用户_60f87
- Bottling3748
- 爱发电用户_7cbeb
- 爱发电用户_6c721
- 哦哦哦哦
- 盒子先生
- 似水流年
- chnxan
- ......
</details>

View File

@@ -18,6 +18,10 @@ ARCH_MAP=(
# Build for specific architecture using cross
build_arch() {
local rust_target="$1"
local feature_args=()
case "$rust_target" in
aarch64-*|armv7-*) feature_args=(--features rkmpp) ;;
esac
case "${CHINAMIRRO:-}" in
1|true|TRUE|yes|YES|on|ON)
@@ -40,13 +44,13 @@ build_arch() {
CARGO_NET_GIT_FETCH_WITH_CLI=true \
RUSTUP_DIST_SERVER="$rust_dist" \
RUSTUP_UPDATE_ROOT="$rust_root" \
cross build --release --target "$rust_target"
cross build --release --target "$rust_target" "${feature_args[@]}"
return
;;
esac
echo "=== Building: $rust_target (via cross with custom Dockerfile) ==="
cross build --release --target "$rust_target"
cross build --release --target "$rust_target" "${feature_args[@]}"
}
# Main

View File

@@ -268,14 +268,18 @@ build_for_platform() {
if [ "$BUILD_BINARY" = true ]; then
echo_info "Building binary with cross..."
cd "$PROJECT_ROOT"
cross build --release --target "$target"
local feature_args=()
case "$target" in
aarch64-*|armv7-*) feature_args=(--features rkmpp) ;;
esac
cross build --release --target "$target" "${feature_args[@]}"
fi
# Check binary exists
local binary="$PROJECT_ROOT/target/$target/release/one-kvm"
if [ ! -f "$binary" ]; then
echo_error "Binary not found: $binary"
echo_error "Build it first: cross build --release --target $target"
echo_error "Build it first: ./build/build-images.sh"
exit 1
fi

View File

@@ -1,24 +0,0 @@
# macOS 拖拽兼容模式(实验性)
适用于 OTG 和 CH9329 后端的绝对鼠标输入。遇到目标 Mac 上拖拽只能移动一小段便停止时,可在设置页的 HID 配置中开启「macOS 拖拽兼容模式」。默认关闭。
配置字段为 `hid.mouse_macos_drag`,旧 PR 的 `ch9329_macos_drag` 字段仍可作为读取别名。OTG 必须同时启用相对鼠标和绝对鼠标接口,否则配置保存会被拒绝。CH9329 同时启用 Linux 兼容开关时,此模式优先。
## 报告行为
- 未按键时仍使用绝对坐标定位。
- 从绝对输入开始的拖拽:按下使用绝对报告,移动和滚轮使用相对报告,释放时先更新相对按钮状态,再发送绝对按钮释放。
- 按键报告的通道保持到所有按钮释放,不随拖拽期间的输入模式切换而改变。
- 从相对输入开始的点击、拖拽继续使用相对按钮报告;此选项不修复原生相对模式的点击兼容问题。
- 位移从原始 15 位输入坐标换算,累计小数余量,大位移按单包范围分包并保留总量及方向。
- 运行时使用当前视频采集尺寸,并在 HID 重载后恢复尺寸;视频尚未提供尺寸时使用配置尺寸。
## 验证范围与限制
本次 OTG 扩展及位移换算修改没有 macOS 实机验证。原 PR 作者对旧 CH9329 实现的实机反馈不能视为本版本已经验证。
本地自动化覆盖位移累计/分包、1080p/4K 换算、往返移动、滚轮、输入模式切换、复位、CH9329 命令队列、OTG 临时文件模拟端点及配置校验。临时文件测试仅验证写出的字节,不验证 USB 枚举或目标系统的事件解释。
相对位移受 macOS 鼠标速度、加速、显示缩放及多屏布局影响,无法保证与绝对定位逐像素一致。松手报告携带最后的客户端绝对坐标,仍可能出现落点偏移或光标跳位;大位移分包也可能增加低波特率串口延迟。
后续实机验证应分别覆盖两个后端:窗口/文件/文本拖拽,快速及慢速移动,滚轮和多按钮组合,1080p/4K 与缩放显示,输入模式切换、断开重连、精确落点。异常时关闭此选项恢复默认行为。

View File

@@ -154,7 +154,7 @@ mod ffmpeg {
let include_dir = Path::new(&custom_lib_path).join("include");
let avcodec_lib = lib_dir.join("libavcodec.a");
if avcodec_lib.exists() {
if avcodec_lib.exists() && use_static {
println!("cargo:info=Using custom FFmpeg from {}", custom_lib_path);
println!("cargo:rustc-link-search=native={}", lib_dir.display());
builder.include(&include_dir);
@@ -171,7 +171,7 @@ mod ffmpeg {
println!("cargo:rustc-link-lib=va-drm");
println!("cargo:rustc-link-lib=va-x11"); // Required for vaGetDisplay
println!("cargo:rustc-link-lib=mfx");
} else {
} else if std::env::var_os("CARGO_FEATURE_RKMPP").is_some() {
for lib in ["rockchip_mpp", "rga"] {
if !lib_dir.join(format!("lib{lib}.a")).exists() {
panic!("missing static library: lib{lib}.a");
@@ -400,8 +400,7 @@ mod ffmpeg {
// RKMPP decode only exists on ARM builds where FFmpeg is compiled with RKMPP support.
// Avoid compiling this file on x86/x64 where `AV_HWDEVICE_TYPE_RKMPP` doesn't exist.
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let enable_rkmpp = matches!(target_arch.as_str(), "aarch64" | "arm")
|| std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
let enable_rkmpp = std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
if enable_rkmpp {
builder.file(ffmpeg_ram_dir.join("ffmpeg_ram_decode.cpp"));
if enable_rkmpp {
@@ -459,8 +458,7 @@ mod ffmpeg {
.unwrap();
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let enable_rkmpp = matches!(target_arch.as_str(), "aarch64" | "arm")
|| std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
let enable_rkmpp = std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
if enable_rkmpp {
// Include RGA headers for NV16->NV12 conversion (RGA im2d API)
let rga_sys_dirs = [

View File

@@ -9,12 +9,12 @@ use std::ffi::c_int;
include!(concat!(env!("OUT_DIR"), "/ffmpeg_ram_ffi.rs"));
#[cfg(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"))]
#[cfg(feature = "rkmpp")]
pub mod decode;
// Provide a small stub on non-ARM builds so dependents can still compile, but decoder
// construction will fail (since the C++ RKMPP decoder isn't built/linked).
#[cfg(not(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp")))]
#[cfg(not(feature = "rkmpp"))]
pub mod decode {
use crate::ffmpeg::AVPixelFormat;

View File

@@ -2,12 +2,12 @@
pub mod capture;
pub mod common;
pub mod ffmpeg;
#[cfg(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"))]
#[cfg(feature = "rkmpp")]
pub mod ffmpeg_hw;
pub mod ffmpeg_ram;
#[cfg(all(
target_os = "linux",
any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp")
feature = "rkmpp"
))]
pub mod rkmpp_dmabuf;

View File

@@ -5,6 +5,7 @@
# <site_name>/v1/channels.json
# <site_name>/v1/releases.json
# <site_name>/v1/bin/<version>/one-kvm-<triple>
# 默认从 GitHub Release 下载 deb,提取二进制;也支持本地二进制输入。
#
set -euo pipefail
@@ -17,6 +18,11 @@ RELEASE_CHANNEL="stable"
STABLE_VERSION=""
BETA_VERSION=""
PUBLISHED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
PUBLISHED_AT_SET=0
GITHUB_REPO="mofeng-git/One-KVM"
GITHUB_RELEASE=""
GITHUB_PROXY="${GITHUB_PROXY:-}"
GITHUB_API_PROXY="${GITHUB_API_PROXY:-}"
ARTIFACTS_DIR=""
X86_64_BIN=""
AARCH64_BIN=""
@@ -32,10 +38,17 @@ declare -a NOTES=()
usage() {
cat <<'EOF'
Usage:
./scripts/build-update-site.sh --version <x.x.x> [options]
./scripts/build-update-site.sh [options]
Required:
--version <x.x.x> Release 版本号(如 0.1.10)
GitHub input (无本地输入时默认使用 latest Release):
--release <tag|url|latest> GitHub Release 标签或完整页面 URL
--github-repo <owner/repo> 默认 mofeng-git/One-KVM
--github-proxy <url> deb 下载代理前缀,如 https://ghproxy.com
代理失败后自动尝试 GitHub 直连
--github-api-proxy <url> 可选 GitHub API 代理前缀
也可设置 GITHUB_PROXY / GITHUB_API_PROXY
--version <x.x.x> GitHub 输入默认从 deb 文件名读取;本地输入必填
GitHub 输入指定时校验版本一致
Artifact input (二选一,可混用):
--artifacts-dir <dir> 自动扫描目录中的标准文件名:
@@ -50,8 +63,8 @@ Manifest options:
--release-channel <stable|beta> releases.json 里该版本所属渠道,默认 stable
--stable <x.x.x> channels.json 的 stable 指针,默认等于 --version
--beta <x.x.x> channels.json 的 beta 指针,默认等于 --version
--published-at <RFC3339> 发布时间,默认当前 UTC 时间
--note <text> 发布说明,可重复传入多次
--published-at <RFC3339> 发布时间,默认 GitHub 发布时间(本地输入为当前时间)
--note <text> 发布说明,可重复;指定后覆盖 GitHub 说明
Output options:
--site-name <name> 打包根目录名,默认 one-kvm-update
@@ -62,6 +75,12 @@ Other:
-h, --help 显示帮助
Example:
./scripts/build-update-site.sh --release v261001
./scripts/build-update-site.sh \
--release https://github.com/mofeng-git/One-KVM/releases/tag/v261001 \
--github-proxy https://ghproxy.com
./scripts/build-update-site.sh \
--version 0.1.10 \
--artifacts-dir ./target/release \
@@ -104,7 +123,26 @@ is_valid_channel() {
}
while [[ $# -gt 0 ]]; do
if [[ "$1" != "-h" && "$1" != "--help" ]]; then
[[ $# -ge 2 && -n "$2" && "$2" != --* ]] || fail "Missing value for $1"
fi
case "$1" in
--release | --github-tag)
GITHUB_RELEASE="$2"
shift 2
;;
--github-repo)
GITHUB_REPO="$2"
shift 2
;;
--github-proxy)
GITHUB_PROXY="$2"
shift 2
;;
--github-api-proxy)
GITHUB_API_PROXY="$2"
shift 2
;;
--version)
VERSION="${2:-}"
shift 2
@@ -123,6 +161,7 @@ while [[ $# -gt 0 ]]; do
;;
--published-at)
PUBLISHED_AT="${2:-}"
PUBLISHED_AT_SET=1
shift 2
;;
--note)
@@ -175,6 +214,128 @@ require_cmd stat
require_cmd tar
require_cmd mktemp
[[ "$SITE_NAME" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || fail "Invalid --site-name: ${SITE_NAME}"
BUILD_DIR="$(mktemp -d)"
trap 'rm -rf "$BUILD_DIR"' EXIT
download_github() {
local url="$1" dest="$2" proxy="$3"
local -a candidates=()
[[ -z "$proxy" ]] || candidates+=("${proxy%/}/${url}")
candidates+=("$url")
local candidate
for candidate in "${candidates[@]}"; do
echo "Downloading: $candidate" >&2
if curl --fail --location --show-error --connect-timeout 15 --max-time 300 \
--retry 2 --retry-delay 2 --output "${dest}.part" "$candidate"; then
# 一些代理返回 HTTP 200 的 HTML 页面,不能当作 deb 或 API 响应。
if [[ "$dest" == *.deb ]]; then
dpkg-deb --info "${dest}.part" >/dev/null 2>&1 || continue
else
python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); assert isinstance(r,dict) and isinstance(r.get("assets"),list)' \
"${dest}.part" >/dev/null 2>&1 || continue
fi
mv "${dest}.part" "$dest"
return 0
fi
echo "Download failed or invalid response; trying next source." >&2
done
fail "Unable to download $url"
}
if [[ -n "$GITHUB_RELEASE" || ( -z "$ARTIFACTS_DIR" && "$X86_64_SET" -eq 0 && "$AARCH64_SET" -eq 0 && "$ARMV7_SET" -eq 0 ) ]]; then
[[ -z "$ARTIFACTS_DIR" && "$X86_64_SET" -eq 0 && "$AARCH64_SET" -eq 0 && "$ARMV7_SET" -eq 0 ]] || \
fail "--release cannot be combined with local artifact input"
require_cmd curl
require_cmd python3
require_cmd dpkg-deb
GITHUB_RELEASE="${GITHUB_RELEASE:-latest}"
if [[ "$GITHUB_RELEASE" =~ ^https://github\.com/([^/]+/[^/]+)/releases/tag/([^/?#]+)/?$ ]]; then
GITHUB_REPO="${BASH_REMATCH[1]}"
GITHUB_RELEASE="${BASH_REMATCH[2]}"
fi
[[ "$GITHUB_REPO" =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ ]] || fail "Invalid --github-repo"
for proxy in "$GITHUB_PROXY" "$GITHUB_API_PROXY"; do
[[ -z "$proxy" || "$proxy" =~ ^https?://[^[:space:]]+$ ]] || fail "Invalid GitHub proxy URL: $proxy"
done
release_path="latest"
if [[ "$GITHUB_RELEASE" != latest ]]; then
release_tag="$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(urllib.parse.unquote(sys.argv[1]), safe=""))' "$GITHUB_RELEASE")"
release_path="tags/${release_tag}"
fi
release_json="${BUILD_DIR}/release.json"
download_github "https://api.github.com/repos/${GITHUB_REPO}/releases/${release_path}" "$release_json" "$GITHUB_API_PROXY"
python3 - "$release_json" "${BUILD_DIR}/assets.tsv" "${BUILD_DIR}/metadata.json" "$VERSION" <<'PY'
import json
import re
import sys
release = json.load(open(sys.argv[1], encoding="utf-8"))
assets = []
seen = set()
for asset in release["assets"]:
match = re.fullmatch(r"one-kvm_(\d+\.\d+\.\d+)_(amd64|arm64|armhf)\.deb", asset["name"])
if not match:
continue
version, arch = match.groups()
if arch in seen:
sys.exit(f"Error: Duplicate deb architecture: {arch}")
seen.add(arch)
url = asset["browser_download_url"]
if not url.startswith("https://github.com/") or any(c in url for c in "\t\r\n"):
sys.exit("Error: Invalid GitHub asset URL")
digest = asset.get("digest") or "-"
if digest != "-" and not re.fullmatch(r"sha256:[a-fA-F0-9]{64}", digest):
sys.exit("Error: Invalid GitHub asset digest")
assets.append((arch, version, url, digest, str(asset["size"])))
if not assets:
sys.exit("Error: Release has no supported One-KVM deb assets")
versions = {a[1] for a in assets}
if len(versions) != 1:
sys.exit("Error: Release deb versions differ")
version = versions.pop()
if sys.argv[4] and sys.argv[4] != version:
sys.exit(f"Error: --version {sys.argv[4]} differs from GitHub deb version {version}")
with open(sys.argv[2], "w", encoding="utf-8") as output:
for asset in assets:
output.write("\t".join(asset) + "\n")
with open(sys.argv[3], "w", encoding="utf-8") as output:
json.dump(dict(version=version, published_at=release["published_at"],
notes=release.get("body") or ""), output, ensure_ascii=False)
PY
VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "${BUILD_DIR}/metadata.json")"
if [[ "$PUBLISHED_AT_SET" -eq 0 ]]; then
PUBLISHED_AT="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["published_at"])' "${BUILD_DIR}/metadata.json")"
fi
if [[ ${#NOTES[@]} -eq 0 ]]; then
mapfile -t NOTES < <(python3 -c 'import json,re,sys; body=json.load(open(sys.argv[1]))["notes"]; print("\n".join(re.sub(r"^[-*+]\s+", "", line.strip()) for line in body.splitlines() if line.strip()), end="")' "${BUILD_DIR}/metadata.json")
fi
ARTIFACTS_DIR="${BUILD_DIR}/binaries"
mkdir -p "$ARTIFACTS_DIR"
while IFS=$'\t' read -r arch deb_version url digest expected_size; do
case "$arch" in
amd64) triple="x86_64-unknown-linux-gnu" ;;
arm64) triple="aarch64-unknown-linux-gnu" ;;
armhf) triple="armv7-unknown-linux-gnueabihf" ;;
esac
deb="${BUILD_DIR}/one-kvm_${deb_version}_${arch}.deb"
download_github "$url" "$deb" "$GITHUB_PROXY"
[[ "$(stat -c%s "$deb")" == "$expected_size" ]] || fail "deb size mismatch: $arch"
if [[ "$digest" != - ]]; then
actual_sha="$(sha256sum "$deb" | awk '{print $1}')"
[[ "$actual_sha" == "${digest#sha256:}" ]] || fail "deb SHA256 mismatch: $arch"
fi
[[ "$(dpkg-deb -f "$deb" Package)" == one-kvm ]] || fail "Unexpected deb package: $arch"
[[ "$(dpkg-deb -f "$deb" Version)" == "$VERSION" ]] || fail "deb version mismatch: $arch"
[[ "$(dpkg-deb -f "$deb" Architecture)" == "$arch" ]] || fail "deb architecture mismatch: $arch"
extract_dir="${BUILD_DIR}/extract-${arch}"
dpkg-deb -x "$deb" "$extract_dir"
binary="${extract_dir}/usr/bin/one-kvm"
[[ -f "$binary" && ! -L "$binary" && -s "$binary" ]] || fail "deb missing /usr/bin/one-kvm: $arch"
cp "$binary" "${ARTIFACTS_DIR}/one-kvm-${triple}"
done < "${BUILD_DIR}/assets.tsv"
fi
[[ -n "$VERSION" ]] || fail "--version is required"
is_valid_version "$VERSION" || fail "Invalid --version: ${VERSION} (expected x.x.x)"
is_valid_channel "$RELEASE_CHANNEL" || fail "Invalid --release-channel: ${RELEASE_CHANNEL}"
@@ -220,9 +381,6 @@ if [[ ${#SRC_BY_TRIPLE[@]} -eq 0 ]]; then
fail "No artifact found. Provide --artifacts-dir or at least one of --x86_64/--aarch64/--armv7."
fi
BUILD_DIR="$(mktemp -d)"
trap 'rm -rf "$BUILD_DIR"' EXIT
SITE_DIR="${BUILD_DIR}/${SITE_NAME}"
V1_DIR="${SITE_DIR}/v1"
BIN_DIR="${V1_DIR}/bin/${VERSION}"
@@ -268,7 +426,7 @@ RELEASES_FILE="${V1_DIR}/releases.json"
echo ' {'
echo " \"version\": \"${VERSION}\","
echo " \"channel\": \"${RELEASE_CHANNEL}\","
echo " \"published_at\": \"${PUBLISHED_AT}\","
echo " \"published_at\": \"$(json_escape "$PUBLISHED_AT")\","
if [[ ${#NOTES[@]} -eq 0 ]]; then
echo ' "notes": [],'

View File

@@ -8,6 +8,8 @@ use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use uuid::Uuid;
use super::netifd_bridge;
use super::network_backend::{current_backend, NetworkBackend};
use crate::error::{AppError, Result};
const BRIDGE_IF: &str = "okvm-br0";
@@ -104,11 +106,24 @@ impl TransactionProfiles {
#[derive(Debug, Clone)]
pub struct NetworkBridgeRuntime {
journal: BridgeJournal,
journal: RuntimeJournal,
}
#[derive(Debug, Clone)]
enum RuntimeJournal {
NetworkManager(BridgeJournal),
Netifd(netifd_bridge::Journal),
}
impl NetworkBridgeRuntime {
pub fn activate(requested: &str, usb_interface: &str) -> Result<Self> {
if current_backend().map_err(AppError::Config)? == NetworkBackend::Netifd {
let interfaces = list_netifd_interfaces()?;
let selected = select_bridge_candidate(&interfaces, requested)?;
return netifd_bridge::activate(selected, usb_interface).map(|journal| Self {
journal: RuntimeJournal::Netifd(journal),
});
}
ensure_command("nmcli")?;
ensure_command("ip")?;
@@ -321,16 +336,23 @@ impl NetworkBridgeRuntime {
})();
match result {
Ok(_address) => Ok(Self { journal }),
Ok(_address) => Ok(Self {
journal: RuntimeJournal::NetworkManager(journal),
}),
Err(error) => Err(restore_or_combine(&journal, error)),
}
}
pub fn deactivate(&self) -> Result<()> {
restore_from_journal(&self.journal)
// Use the backend recorded at activation, even if the environment changes.
match &self.journal {
RuntimeJournal::NetworkManager(journal) => restore_from_journal(journal),
RuntimeJournal::Netifd(journal) => netifd_bridge::deactivate(journal),
}
}
pub fn recover_stale_transaction() -> Result<()> {
netifd_bridge::recover()?;
let value = match fs::read_to_string(JOURNAL_PATH) {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
@@ -340,6 +362,7 @@ impl NetworkBridgeRuntime {
)))
}
};
ensure_command("nmcli")?;
match serde_json::from_str::<BridgeJournal>(&value) {
Ok(journal) if journal.version == JOURNAL_VERSION => restore_from_journal(&journal),
Ok(journal) => Err(AppError::Config(format!(
@@ -354,10 +377,92 @@ impl NetworkBridgeRuntime {
}
pub fn list_network_interfaces() -> Result<Vec<NetworkInterfaceInfo>> {
if current_backend().map_err(AppError::Config)? == NetworkBackend::Netifd {
return list_netifd_interfaces();
}
let devices = enumerate_network_manager_devices()?;
Ok(bridge_candidates(devices, is_physical_network_interface))
}
fn list_netifd_interfaces() -> Result<Vec<NetworkInterfaceInfo>> {
let output = run_command("ubus", &["call", "network.interface", "dump"])?;
let value: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|error| AppError::Internal(format!("Invalid netifd interface dump: {error}")))?;
parse_netifd_interfaces(&value)
}
fn parse_netifd_interfaces(value: &serde_json::Value) -> Result<Vec<NetworkInterfaceInfo>> {
parse_netifd_interfaces_with_check(value, netifd_bridge::bridgeable_device)
}
fn parse_netifd_interfaces_with_check(
value: &serde_json::Value,
bridgeable: impl Fn(&str) -> bool,
) -> Result<Vec<NetworkInterfaceInfo>> {
let interfaces = value
.get("interface")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| AppError::Internal("netifd dump is missing interface array".to_string()))?;
Ok(interfaces
.iter()
.filter_map(|interface| {
let logical_name = interface.get("interface")?.as_str()?;
let name = interface
.get("l3_device")
.or_else(|| interface.get("device"))?
.as_str()?;
if name == "lo" {
return None;
}
let up = interface
.get("up")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let addresses: Vec<String> = interface
.get("ipv4-address")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(|address| {
let ip = address.get("address")?.as_str()?;
let mask = address.get("mask")?.as_u64()?;
Some(format!("{ip}/{mask}"))
})
.collect();
let has_default_route = interface
.get("route")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.any(|route| {
route.get("target").and_then(serde_json::Value::as_str) == Some("0.0.0.0")
&& route.get("mask").and_then(serde_json::Value::as_u64) == Some(0)
});
let reason = if !up {
Some("Network interface is disconnected".to_string())
} else if !matches!(interface.get("proto").and_then(serde_json::Value::as_str), Some("dhcp" | "static")) {
Some("OTG bridging supports DHCP and static Ethernet uplinks".to_string())
} else if !bridgeable(name) {
Some("OTG uplink must be Ethernet or an existing bridge; ordinary Wi-Fi clients cannot be bridged".to_string())
} else if addresses.is_empty() {
Some("OTG bridging requires an IPv4 uplink".to_string())
} else {
None
};
Some(NetworkInterfaceInfo {
name: name.to_string(),
interface_type: "netifd".to_string(),
state: if up { "connected" } else { "disconnected" }.to_string(),
connection: logical_name.to_string(),
addresses,
has_default_route,
bridge_supported: reason.is_none(),
reason,
})
})
.collect())
}
fn enumerate_network_manager_devices() -> Result<Vec<NetworkManagerDevice>> {
ensure_command("nmcli")?;
let output = run_command(
@@ -440,10 +545,11 @@ fn select_bridge_candidate<'a>(
if requested.trim().is_empty() {
return interfaces
.iter()
.filter(|item| item.bridge_supported)
.max_by_key(|item| item.has_default_route)
.ok_or_else(|| {
AppError::Config(
"No connected physical NetworkManager Ethernet interface is available for OTG bridging"
"No connected Ethernet interface or supported bridge is available for OTG bridging"
.to_string(),
)
});
@@ -451,10 +557,15 @@ fn select_bridge_candidate<'a>(
interfaces
.iter()
.find(|item| item.name == requested)
.find(|item| item.name == requested && item.bridge_supported)
.ok_or_else(|| {
AppError::Config(format!(
"Network interface {requested} is not a connected physical NetworkManager Ethernet interface"
"Network interface {requested} does not support OTG bridging: {}",
interfaces
.iter()
.find(|item| item.name == requested)
.and_then(|item| item.reason.as_deref())
.unwrap_or("interface unavailable")
))
})
}
@@ -759,7 +870,7 @@ fn connection_value(connection: &str, property: &str) -> Result<String> {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn first_ipv4_address(interface: &str) -> Option<String> {
pub(super) fn first_ipv4_address(interface: &str) -> Option<String> {
ipv4_addresses(interface).into_iter().next()
}
@@ -783,7 +894,7 @@ fn ipv4_addresses(interface: &str) -> Vec<String> {
.collect()
}
fn default_route(interface: &str) -> Option<String> {
pub(super) fn default_route(interface: &str) -> Option<String> {
let output = Command::new("ip")
.args(["-4", "route", "show", "default", "dev", interface])
.output()
@@ -801,7 +912,7 @@ fn gateway_from_route(route: &str) -> Option<&str> {
.find_map(|part| (part[0] == "via").then_some(part[1]))
}
fn ensure_command(name: &str) -> Result<()> {
pub(super) fn ensure_command(name: &str) -> Result<()> {
let status = Command::new(name).arg("--version").output();
if status.is_err() {
return Err(AppError::BadRequest(format!(
@@ -815,7 +926,7 @@ fn run_nmcli(args: &[&str]) -> Result<Output> {
run_command("nmcli", args)
}
fn run_command(command: &str, args: &[&str]) -> Result<Output> {
pub(super) fn run_command(command: &str, args: &[&str]) -> Result<Output> {
let output = Command::new(command)
.env("LC_ALL", "C")
.args(args)
@@ -842,6 +953,49 @@ fn run_command(command: &str, args: &[&str]) -> Result<Output> {
mod tests {
use super::*;
#[test]
fn netifd_dump_preserves_logical_name_addresses_and_default_route() {
let dump = serde_json::json!({"interface": [
{"interface": "loopback", "l3_device": "lo", "up": true},
{"interface": "lan", "l3_device": "br-lan", "up": true, "proto": "dhcp",
"ipv4-address": [{"address": "192.0.2.5", "mask": 24}],
"route": [{"target": "0.0.0.0", "mask": 0}]},
{"interface": "offline", "device": "eth1", "up": false}
]});
let result = parse_netifd_interfaces_with_check(&dump, |_| true).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].name, "br-lan");
assert_eq!(result[0].connection, "lan");
assert_eq!(result[0].addresses, vec!["192.0.2.5/24"]);
assert!(result[0].has_default_route);
assert!(result[0].bridge_supported);
assert!(result[0].reason.is_none());
assert_eq!(result[1].state, "disconnected");
assert!(!result[1].has_default_route);
}
#[test]
fn malformed_netifd_dump_fails_and_incomplete_entries_are_skipped() {
assert!(parse_netifd_interfaces(&serde_json::json!({})).is_err());
assert!(parse_netifd_interfaces(&serde_json::json!({"interface": {}})).is_err());
let dump = serde_json::json!({"interface": [{"interface": "pending"}, {}]});
assert!(parse_netifd_interfaces(&dump).unwrap().is_empty());
}
#[test]
fn netifd_selection_skips_wifi_ppp_and_ipv6_only_uplinks() {
let dump = serde_json::json!({"interface": [
{"interface":"wifi", "l3_device":"wlan0", "up":true, "proto":"dhcp", "ipv4-address":[{"address":"192.0.2.6","mask":24}], "route":[{"target":"0.0.0.0","mask":0}]},
{"interface":"wan", "l3_device":"pppoe-wan", "up":true, "proto":"pppoe"},
{"interface":"lan6", "l3_device":"eth1", "up":true, "proto":"static"},
{"interface":"lan", "l3_device":"eth0", "up":true, "proto":"dhcp", "ipv4-address":[{"address":"192.0.2.5","mask":24}]}
]});
let interfaces = parse_netifd_interfaces_with_check(&dump, |name| name.starts_with("eth")).unwrap();
assert_eq!(select_bridge_candidate(&interfaces, "").unwrap().name, "eth0");
assert!(select_bridge_candidate(&interfaces, "wlan0").is_err());
assert!(select_bridge_candidate(&interfaces, "eth1").is_err());
}
fn device(
name: &str,
interface_type: &str,
@@ -994,6 +1148,6 @@ mod tests {
let error = select_bridge_candidate(&[], "").unwrap_err();
assert!(matches!(error, AppError::Config(_)));
assert!(error.to_string().contains("connected physical"));
assert!(error.to_string().contains("connected Ethernet"));
}
}

View File

@@ -14,6 +14,10 @@ pub mod manager;
pub mod msd;
#[cfg(unix)]
pub mod network;
#[cfg(unix)]
mod network_backend;
#[cfg(unix)]
mod netifd_bridge;
pub mod report_desc;
pub mod self_check;
#[cfg(unix)]

826
src/otg/netifd_bridge.rs Normal file
View File

@@ -0,0 +1,826 @@
//! OpenWrt bridge transactions. Preserve logical interfaces and their L3 settings.
//! The recovery journal lives in /etc so committed UCI changes survive a reboot safely.
use super::bridge::{
default_route, ensure_command, first_ipv4_address, run_command, NetworkInterfaceInfo,
};
use crate::error::{AppError, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::{
collections::BTreeMap,
fs,
io::Write,
os::unix::fs::PermissionsExt,
path::Path,
thread,
time::{Duration, Instant},
};
use uuid::Uuid;
const JOURNAL_PATH: &str = "/etc/one-kvm/otg-netifd-bridge.json";
const BRIDGE: &str = "okvm-br0";
const VERSION: u8 = 1;
type Config = BTreeMap<String, Map<String, Value>>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct Journal {
version: u8,
uplink: String,
usb: String,
bridge: String,
had_default_route: bool,
interface_devices: BTreeMap<String, String>,
created_section: Option<String>,
bridge_section: String,
// Exact owned section options; unrelated user edits are preserved on recovery.
created_options: Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq)]
enum Edit {
Set(String, String),
Delete(String),
AddList(String, String),
DelList(String, String),
}
fn invalid(message: impl Into<String>) -> AppError {
AppError::Config(message.into())
}
fn io_error(error: impl std::fmt::Display) -> AppError {
AppError::Internal(format!("OpenWrt bridge: {error}"))
}
fn identifier(name: &str) -> bool {
!name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
}
fn device_name(name: &str) -> bool {
!name.is_empty()
&& name.len() < 16
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"_-.".contains(&b))
}
fn option<'a>(section: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
section.get(key)?.as_str()
}
fn ports(section: &Map<String, Value>) -> Result<Vec<String>> {
match section.get("ports") {
None => Ok(Vec::new()),
Some(Value::Array(values)) => values
.iter()
.map(|v| {
v.as_str()
.map(str::to_owned)
.ok_or_else(|| invalid("Invalid UCI bridge port list"))
})
.collect(),
Some(Value::String(value)) => Ok(value.split_whitespace().map(str::to_owned).collect()),
_ => Err(invalid("Invalid UCI bridge ports")),
}
}
pub(super) fn bridgeable_device(name: &str) -> bool {
if !device_name(name) {
return false;
}
let path = Path::new("/sys/class/net").join(name);
path.join("bridge").is_dir()
|| (path.join("device").exists()
&& fs::read_to_string(path.join("type")).is_ok_and(|value| value.trim() == "1")
&& !path.join("wireless").exists()
&& !path.join("phy80211").exists())
}
fn plan(
config: &Config,
selected: &NetworkInterfaceInfo,
usb: &str,
mac: &str,
existing_bridge: bool,
) -> Result<(Journal, Vec<Edit>)> {
if !device_name(&selected.name)
|| !device_name(usb)
|| selected.name == usb
|| !identifier(&selected.connection)
{
return Err(invalid(
"Invalid or identical OpenWrt uplink and USB interfaces",
));
}
let interface = config
.get(&selected.connection)
.ok_or_else(|| invalid("Selected UCI interface no longer exists"))?;
if option(interface, ".type") != Some("interface")
|| option(interface, "device") != Some(selected.name.as_str())
|| interface.contains_key("ifname")
|| interface.contains_key("type")
{
return Err(invalid("OTG bridging requires a current UCI interface with a device option; legacy ifname/type configurations are unsupported"));
}
if !matches!(option(interface, "proto"), Some("dhcp" | "static")) {
return Err(invalid(
"OTG bridging supports DHCP and static Ethernet uplinks",
));
}
for section in config.values() {
if option(section, ".type") == Some("interface")
&& option(section, "ifname")
.is_some_and(|value| value.split_whitespace().any(|name| name == selected.name))
{
return Err(invalid("Another UCI interface uses the uplink through legacy ifname; migrate it to a device option first"));
}
if option(section, "device") == Some(usb)
|| option(section, "ifname").is_some_and(|v| v.split_whitespace().any(|p| p == usb))
|| (option(section, ".type") == Some("device")
&& ports(section)?.iter().any(|p| p == usb))
{
return Err(invalid(
"USB interface already belongs to an OpenWrt network configuration",
));
}
}
let mut journal = Journal {
version: VERSION,
uplink: selected.name.clone(),
usb: usb.to_owned(),
bridge: selected.name.clone(),
had_default_route: selected.has_default_route,
interface_devices: BTreeMap::new(),
created_section: None,
bridge_section: String::new(),
created_options: Map::new(),
};
let mut edits = Vec::new();
if existing_bridge {
let matches: Vec<_> = config
.iter()
.filter(|(_, s)| {
option(s, ".type") == Some("device")
&& option(s, "name") == Some(selected.name.as_str())
})
.collect();
if matches.len() != 1 || option(matches[0].1, "type") != Some("bridge") {
return Err(invalid("Existing bridge must have one UCI device section"));
}
let (name, section) = matches[0];
if !identifier(name)
|| matches!(
option(section, "vlan_filtering"),
Some("1" | "true" | "yes" | "on")
)
|| config.values().any(|s| {
option(s, ".type") == Some("bridge-vlan")
&& option(s, "device") == Some(selected.name.as_str())
})
{
return Err(invalid("VLAN-filtered bridges require an explicit USB VLAN configuration and are unsupported"));
}
// Preserve list semantics; a scalar ports option is not safe to edit with add_list.
if section.get("ports").is_some_and(|v| !v.is_array()) {
return Err(invalid("Bridge ports must use UCI list entries"));
}
journal.bridge_section = name.clone();
edits.push(Edit::AddList(format!("{name}.ports"), usb.to_owned()));
} else {
if config.values().any(|s| option(s, "name") == Some(BRIDGE)) {
return Err(invalid(
"okvm-br0 already exists; refusing to replace another bridge",
));
}
if config.values().any(|s| {
option(s, ".type") == Some("device")
&& ports(s).is_ok_and(|p| p.iter().any(|p| p == &selected.name))
}) {
return Err(invalid(
"Selected uplink already belongs to a configured bridge",
));
}
let section = format!("one_kvm_otg_{}", Uuid::new_v4().simple());
journal.bridge = BRIDGE.to_owned();
journal.bridge_section = section.clone();
journal.created_section = Some(section.clone());
journal.created_options = json!({".type":"device", "name":BRIDGE, "type":"bridge", "stp":"0", "macaddr":mac, "ports":[selected.name, usb]}).as_object().unwrap().clone();
edits.push(Edit::Set(section.clone(), "device".to_owned()));
for key in ["name", "type", "stp", "macaddr"] {
edits.push(Edit::Set(
format!("{section}.{key}"),
option(&journal.created_options, key).unwrap().to_owned(),
));
}
for port in [selected.name.as_str(), usb] {
edits.push(Edit::AddList(format!("{section}.ports"), port.to_owned()));
}
for (name, section) in config {
if option(section, ".type") != Some("interface")
|| option(section, "device") != Some(selected.name.as_str())
{
continue;
}
if !identifier(name)
|| section.contains_key("ifname")
|| section.contains_key("type")
|| !matches!(
option(section, "proto"),
Some("dhcp" | "static" | "dhcpv6" | "none")
)
{
return Err(invalid("An additional uplink interface has an unsupported protocol or legacy configuration"));
}
journal
.interface_devices
.insert(name.clone(), selected.name.clone());
edits.push(Edit::Set(format!("{name}.device"), BRIDGE.to_owned()));
}
}
Ok((journal, edits))
}
fn restore_plan(config: &Config, journal: &Journal) -> Result<Vec<Edit>> {
if journal.version != VERSION
|| !identifier(&journal.bridge_section)
|| !device_name(&journal.uplink)
|| !device_name(&journal.usb)
|| !device_name(&journal.bridge)
{
return Err(invalid("Invalid OpenWrt bridge recovery journal"));
}
let mut edits = Vec::new();
for (name, original) in &journal.interface_devices {
if !identifier(name) {
return Err(invalid("Invalid recovery interface name"));
}
let section = config.get(name).ok_or_else(|| {
invalid(format!(
"Recovery interface {name} was removed; journal retained"
))
})?;
match option(section, "device") {
Some(value) if value == original => {},
Some(value) if value == journal.bridge => edits.push(Edit::Set(format!("{name}.device"), original.clone())),
_ => return Err(invalid(format!("Interface {name} was changed externally; refusing to overwrite it; journal retained"))),
}
}
if let Some(name) = &journal.created_section {
if name != &journal.bridge_section || !name.starts_with("one_kvm_otg_") {
return Err(invalid("Invalid owned bridge section"));
}
if let Some(section) = config.get(name) {
let actual: Map<_, _> = section
.iter()
.filter(|(key, _)| !key.starts_with('.') || key.as_str() == ".type")
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
if actual != journal.created_options {
return Err(invalid(
"Owned bridge was changed externally; refusing to delete it; journal retained",
));
}
if config.iter().any(|(name, s)| {
!journal.interface_devices.contains_key(name)
&& option(s, "device") == Some(journal.bridge.as_str())
}) {
return Err(invalid(
"Another interface now uses the owned bridge; journal retained",
));
}
edits.push(Edit::Delete(name.clone()));
}
} else {
let section = config
.get(&journal.bridge_section)
.ok_or_else(|| invalid("Existing bridge was removed; journal retained"))?;
if option(section, "name") != Some(journal.bridge.as_str())
|| option(section, "type") != Some("bridge")
{
return Err(invalid("Existing bridge was replaced; journal retained"));
}
if ports(section)?.contains(&journal.usb) {
edits.push(Edit::DelList(
format!("{}.ports", journal.bridge_section),
journal.usb.clone(),
));
}
}
Ok(edits)
}
trait Control {
fn config(&mut self) -> Result<Config>;
fn apply(&mut self, original: &Config, edits: &[Edit]) -> Result<()>;
fn reload(&mut self) -> Result<()>;
fn wait(&mut self, journal: &Journal, active: bool) -> Result<()>;
fn save(&mut self, journal: &Journal) -> Result<()>;
fn clear(&mut self) -> Result<()>;
}
fn activate_with(
control: &mut impl Control,
selected: &NetworkInterfaceInfo,
usb: &str,
mac: &str,
existing_bridge: bool,
) -> Result<Journal> {
let config = control.config()?;
let (journal, edits) = plan(&config, selected, usb, mac, existing_bridge)?;
control.save(&journal)?; // Durable recovery precedes every configuration change.
let result = control
.apply(&config, &edits)
.and_then(|()| control.reload())
.and_then(|()| control.wait(&journal, true));
if let Err(primary) = result {
return Err(match restore_with(control, &journal) {
Ok(()) => primary,
Err(rollback) => invalid(format!(
"{primary}; OpenWrt bridge rollback failed: {rollback}"
)),
});
}
Ok(journal)
}
fn restore_with(control: &mut impl Control, journal: &Journal) -> Result<()> {
let config = control.config()?;
let edits = restore_plan(&config, journal)?;
if !edits.is_empty() {
control.apply(&config, &edits)?;
}
control.reload()?;
control.wait(journal, false)?;
control.clear()
}
struct System;
impl Control for System {
fn config(&mut self) -> Result<Config> {
if Path::new("/var/run/uci/network").exists() {
return Err(invalid("A runtime network UCI override is active; OTG bridge configuration cannot safely replace it"));
}
let pending = run_command("uci", &["-q", "changes", "network"])?;
if !pending.stdout.is_empty() {
return Err(invalid(
"Save or discard pending network UCI changes before changing the OTG bridge",
));
}
let output = run_command("ubus", &["call", "uci", "get", r#"{"config":"network"}"#])?;
let value: Value = serde_json::from_slice(&output.stdout).map_err(io_error)?;
serde_json::from_value(value.get("values").cloned().ok_or_else(|| {
invalid("Invalid ubus UCI network response; rpcd UCI service is required")
})?)
.map_err(io_error)
}
fn apply(&mut self, original: &Config, edits: &[Edit]) -> Result<()> {
if &self.config()? != original {
return Err(invalid(
"Network configuration changed concurrently; retry the bridge operation",
));
}
let path = Path::new("/etc/config/network");
if path.is_symlink() {
return Err(invalid("Symlinked network configuration is unsupported"));
}
let before = fs::read(path).map_err(io_error)?;
let directory = tempfile::tempdir().map_err(io_error)?;
let copy = directory.path().join("network");
fs::write(&copy, &before).map_err(io_error)?;
let delta = directory.path().join("delta");
fs::create_dir(&delta).map_err(io_error)?;
let confdir = directory
.path()
.to_str()
.ok_or_else(|| invalid("Invalid UCI temporary directory"))?;
let savedir = delta
.to_str()
.ok_or_else(|| invalid("Invalid UCI delta directory"))?;
for edit in edits {
let (command, argument) = match edit {
Edit::Set(key, value) => ("set", format!("network.{key}={value}")),
Edit::Delete(key) => ("delete", format!("network.{key}")),
Edit::AddList(key, value) => ("add_list", format!("network.{key}={value}")),
Edit::DelList(key, value) => ("del_list", format!("network.{key}={value}")),
};
run_command(
"uci",
&["-q", "-c", confdir, "-t", savedir, command, &argument],
)?;
}
run_command(
"uci",
&["-q", "-c", confdir, "-t", savedir, "commit", "network"],
)?;
let mut replacement =
tempfile::NamedTempFile::new_in(path.parent().unwrap()).map_err(io_error)?;
replacement
.as_file()
.set_permissions(fs::metadata(path).map_err(io_error)?.permissions())
.map_err(io_error)?;
replacement
.write_all(&fs::read(&copy).map_err(io_error)?)
.map_err(io_error)?;
replacement.as_file().sync_all().map_err(io_error)?;
if fs::read(path).map_err(io_error)? != before || &self.config()? != original {
return Err(invalid(
"Network configuration changed concurrently; original configuration was preserved",
));
}
replacement.persist(path).map_err(io_error)?;
fs::File::open(path.parent().unwrap())
.and_then(|f| f.sync_all())
.map_err(io_error)
}
fn reload(&mut self) -> Result<()> {
run_command("ubus", &["call", "network", "reload"]).map(|_| ())
}
fn wait(&mut self, journal: &Journal, active: bool) -> Result<()> {
let interface = if active {
&journal.bridge
} else {
&journal.uplink
};
let deadline = Instant::now() + Duration::from_secs(35);
while Instant::now() < deadline {
let members = !active
|| (Path::new("/sys/class/net")
.join(interface)
.join("brif")
.join(&journal.usb)
.exists()
&& (journal.created_section.is_none()
|| Path::new("/sys/class/net")
.join(interface)
.join("brif")
.join(&journal.uplink)
.exists()));
if members
&& first_ipv4_address(interface).is_some()
&& (!journal.had_default_route || default_route(interface).is_some())
{
return Ok(());
}
thread::sleep(Duration::from_secs(1));
}
Err(invalid(format!("OpenWrt interface {interface} did not restore its IPv4 address/default route or bridge ports in time")))
}
fn save(&mut self, journal: &Journal) -> Result<()> {
let path = Path::new(JOURNAL_PATH);
if path.exists() {
return Err(invalid("An OpenWrt bridge recovery journal already exists; recover it before activating another bridge"));
}
fs::create_dir_all(path.parent().unwrap()).map_err(io_error)?;
let mut file = tempfile::NamedTempFile::new_in(path.parent().unwrap()).map_err(io_error)?;
file.as_file()
.set_permissions(fs::Permissions::from_mode(0o600))
.map_err(io_error)?;
file.write_all(&serde_json::to_vec(journal).map_err(io_error)?)
.map_err(io_error)?;
file.as_file().sync_all().map_err(io_error)?;
file.persist_noclobber(path).map_err(io_error)?;
fs::File::open(path.parent().unwrap())
.and_then(|f| f.sync_all())
.map_err(io_error)
}
fn clear(&mut self) -> Result<()> {
match fs::remove_file(JOURNAL_PATH) {
Ok(()) => fs::File::open(Path::new(JOURNAL_PATH).parent().unwrap())
.and_then(|f| f.sync_all())
.map_err(io_error),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(io_error(error)),
}
}
}
pub(super) fn activate(selected: &NetworkInterfaceInfo, usb: &str) -> Result<Journal> {
for name in ["uci", "ubus", "ip"] {
ensure_command(name)?;
}
if !bridgeable_device(&selected.name) {
return Err(invalid("OpenWrt bridge uplink must be Ethernet or an existing bridge; ordinary Wi-Fi clients cannot be bridged"));
}
let existing_bridge = Path::new("/sys/class/net")
.join(&selected.name)
.join("bridge")
.is_dir();
if existing_bridge
&& fs::read_to_string(
Path::new("/sys/class/net")
.join(&selected.name)
.join("bridge/vlan_filtering"),
)
.is_ok_and(|value| value.trim() == "1")
{
return Err(invalid(
"VLAN-filtered bridges require an explicit USB VLAN configuration and are unsupported",
));
}
if !existing_bridge && Path::new("/sys/class/net").join(BRIDGE).exists() {
return Err(invalid(
"okvm-br0 already exists; refusing to replace another bridge",
));
}
let usb_path = Path::new("/sys/class/net").join(usb);
if !device_name(usb) || !usb_path.is_dir() || usb_path.join("master").exists() {
return Err(invalid(
"USB network interface is unavailable or already enslaved",
));
}
let mac = fs::read_to_string(
Path::new("/sys/class/net")
.join(&selected.name)
.join("address"),
)
.map_err(io_error)?;
activate_with(&mut System, selected, usb, mac.trim(), existing_bridge)
}
pub(super) fn deactivate(journal: &Journal) -> Result<()> {
restore_with(&mut System, journal)
}
pub(super) fn recover() -> Result<()> {
let contents = match fs::read(JOURNAL_PATH) {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(io_error(error)),
};
let journal: Journal = serde_json::from_slice(&contents).map_err(io_error)?;
restore_with(&mut System, &journal)
}
#[cfg(test)]
mod tests {
use super::*;
struct Fake {
config: Config,
journal: Option<Journal>,
reload_failures: usize,
fail_active_wait: bool,
saved_before_apply: bool,
}
impl Control for Fake {
fn config(&mut self) -> Result<Config> {
Ok(self.config.clone())
}
fn apply(&mut self, original: &Config, edits: &[Edit]) -> Result<()> {
assert_eq!(original, &self.config);
assert!(self.journal.is_some());
self.saved_before_apply = true;
for edit in edits {
match edit {
Edit::Set(key, value) => match key.split_once('.') {
Some((name, key)) => {
self.config
.get_mut(name)
.unwrap()
.insert(key.to_owned(), json!(value));
}
None => {
self.config.insert(
key.clone(),
json!({".type":value}).as_object().unwrap().clone(),
);
}
},
Edit::Delete(key) => {
self.config.remove(key);
}
Edit::AddList(key, value) => {
let (name, key) = key.split_once('.').unwrap();
self.config
.get_mut(name)
.unwrap()
.entry(key.to_owned())
.or_insert(json!([]))
.as_array_mut()
.unwrap()
.push(json!(value));
}
Edit::DelList(key, value) => {
let (name, key) = key.split_once('.').unwrap();
self.config
.get_mut(name)
.unwrap()
.get_mut(key)
.unwrap()
.as_array_mut()
.unwrap()
.retain(|v| v != value);
}
}
}
Ok(())
}
fn reload(&mut self) -> Result<()> {
if self.reload_failures > 0 {
self.reload_failures -= 1;
return Err(invalid("simulated reload failure"));
}
Ok(())
}
fn wait(&mut self, _: &Journal, active: bool) -> Result<()> {
if active && self.fail_active_wait {
Err(invalid("simulated DHCP timeout"))
} else {
Ok(())
}
}
fn save(&mut self, journal: &Journal) -> Result<()> {
assert!(self.journal.is_none());
self.journal = Some(journal.clone());
Ok(())
}
fn clear(&mut self) -> Result<()> {
self.journal = None;
Ok(())
}
}
fn fixture(proto: &str) -> (Fake, NetworkInterfaceInfo) {
let config: Config = serde_json::from_value(json!({
"lan":{".type":"interface", "device":"eth0", "proto":proto, "ipaddr":"192.0.2.5", "dns":["192.0.2.1"], "gateway":"192.0.2.1"},
"lan6":{".type":"interface", "device":"eth0", "proto":"dhcpv6"},
"wan":{".type":"interface", "device":"eth1", "proto":"dhcp"}
})).unwrap();
let selected = NetworkInterfaceInfo {
name: "eth0".into(),
connection: "lan".into(),
interface_type: "netifd".into(),
state: "connected".into(),
addresses: vec!["192.0.2.5/24".into()],
has_default_route: true,
bridge_supported: true,
reason: None,
};
(
Fake {
config,
journal: None,
reload_failures: 0,
fail_active_wait: false,
saved_before_apply: false,
},
selected,
)
}
#[test]
fn ethernet_transaction_keeps_static_ip_dns_routes_ipv6_and_other_networks() {
let (mut fake, selected) = fixture("static");
let original = fake.config.clone();
let journal =
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", false).unwrap();
assert!(fake.saved_before_apply);
for name in ["lan", "lan6"] {
let mut expected = original[name].clone();
expected.insert("device".into(), json!(BRIDGE));
assert_eq!(fake.config[name], expected);
}
assert_eq!(fake.config["wan"], original["wan"]);
assert_eq!(
fake.config[&journal.bridge_section]["macaddr"],
"02:00:00:00:00:01"
);
fake.config
.get_mut("wan")
.unwrap()
.insert("metric".into(), json!("50"));
restore_with(&mut fake, &journal).unwrap();
assert_eq!(fake.config["lan"], original["lan"]);
assert_eq!(fake.config["lan6"], original["lan6"]);
assert_eq!(fake.config["wan"]["metric"], "50");
assert!(fake.journal.is_none());
assert!(!fake.config.contains_key(&journal.bridge_section));
}
#[test]
fn existing_bridge_only_adds_and_removes_usb_preserving_new_ports() {
let (mut fake, mut selected) = fixture("dhcp");
selected.name = "br-lan".into();
fake.config
.get_mut("lan")
.unwrap()
.insert("device".into(), json!("br-lan"));
fake.config.insert("cfg123".into(), json!({".type":"device", "name":"br-lan", "type":"bridge", "ports":["eth0"], "stp":"1"}).as_object().unwrap().clone());
let journal =
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", true).unwrap();
assert!(journal.created_section.is_none());
assert_eq!(fake.config["cfg123"]["ports"], json!(["eth0", "usb0"]));
fake.config
.get_mut("cfg123")
.unwrap()
.get_mut("ports")
.unwrap()
.as_array_mut()
.unwrap()
.push(json!("eth2"));
restore_with(&mut fake, &journal).unwrap();
assert_eq!(fake.config["cfg123"]["ports"], json!(["eth0", "eth2"]));
assert_eq!(fake.config["cfg123"]["stp"], "1");
assert_eq!(fake.config["lan"]["device"], "br-lan");
}
#[test]
fn reload_failure_and_dhcp_timeout_restore_original_before_returning_error() {
for reload_failure in [true, false] {
let (mut fake, selected) = fixture("dhcp");
let original = fake.config.clone();
fake.reload_failures = usize::from(reload_failure);
fake.fail_active_wait = !reload_failure;
assert!(
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", false).is_err()
);
assert_eq!(fake.config, original);
assert!(fake.journal.is_none());
}
}
#[test]
fn failed_rollback_keeps_journal_and_can_be_retried() {
let (mut fake, selected) = fixture("dhcp");
let original = fake.config.clone();
fake.reload_failures = 2;
let error =
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", false).unwrap_err();
assert!(error.to_string().contains("rollback failed"));
let journal = fake.journal.clone().unwrap();
restore_with(&mut fake, &journal).unwrap();
assert_eq!(fake.config, original);
assert!(fake.journal.is_none());
}
#[test]
fn reboot_recovery_accepts_serialized_journal_and_already_restored_config() {
let (mut fake, selected) = fixture("dhcp");
let original = fake.config.clone();
let journal =
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", false).unwrap();
let recovered: Journal =
serde_json::from_slice(&serde_json::to_vec(&journal).unwrap()).unwrap();
restore_with(&mut fake, &recovered).unwrap();
fake.journal = Some(recovered.clone());
restore_with(&mut fake, &recovered).unwrap();
assert_eq!(fake.config, original);
}
#[test]
fn externally_changed_interface_or_owned_bridge_is_never_overwritten() {
for change_interface in [true, false] {
let (mut fake, selected) = fixture("dhcp");
let journal =
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", false).unwrap();
let (name, key, value) = if change_interface {
("lan", "device", "eth2")
} else {
(journal.bridge_section.as_str(), "stp", "1")
};
fake.config
.get_mut(name)
.unwrap()
.insert(key.into(), json!(value));
let before = fake.config.clone();
assert!(restore_with(&mut fake, &journal).is_err());
assert_eq!(fake.config, before);
assert!(fake.journal.is_some());
}
}
#[test]
fn rejects_ppp_legacy_and_busy_usb_before_writing_journal() {
for kind in 0..3 {
let (mut fake, selected) = fixture(if kind == 0 { "pppoe" } else { "dhcp" });
if kind == 1 {
fake.config
.get_mut("lan")
.unwrap()
.insert("ifname".into(), json!("eth0"));
}
if kind == 2 {
fake.config
.get_mut("wan")
.unwrap()
.insert("device".into(), json!("usb0"));
}
let before = fake.config.clone();
assert!(
activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", false).is_err()
);
assert_eq!(fake.config, before);
assert!(fake.journal.is_none());
}
}
#[test]
fn rejects_vlan_filtered_bridge_without_mutations() {
let (mut fake, mut selected) = fixture("dhcp");
selected.name = "br-lan".into();
fake.config
.get_mut("lan")
.unwrap()
.insert("device".into(), json!("br-lan"));
fake.config.insert(
"bridge".into(),
json!({".type":"device", "name":"br-lan", "type":"bridge", "ports":["eth0"]})
.as_object()
.unwrap()
.clone(),
);
fake.config.insert(
"vlan".into(),
json!({".type":"bridge-vlan", "device":"br-lan", "vlan":"1"})
.as_object()
.unwrap()
.clone(),
);
assert!(activate_with(&mut fake, &selected, "usb0", "02:00:00:00:00:01", true).is_err());
assert!(fake.journal.is_none());
}
}

View File

@@ -0,0 +1,74 @@
//! Select OpenWrt netifd or a running NetworkManager, with an explicit override.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NetworkBackend {
NetworkManager,
Netifd,
}
pub(crate) fn select_backend(
requested: Option<&str>,
openwrt_release_present: bool,
) -> Result<NetworkBackend, String> {
match requested.unwrap_or("auto") {
"auto" if openwrt_release_present => Ok(NetworkBackend::Netifd),
"auto" | "networkmanager" => Ok(NetworkBackend::NetworkManager),
"netifd" => Ok(NetworkBackend::Netifd),
value => Err(format!(
"Unknown ONE_KVM_NETWORK_BACKEND={value}; expected auto, networkmanager or netifd"
)),
}
}
pub(crate) fn current_backend() -> Result<NetworkBackend, String> {
let requested = std::env::var("ONE_KVM_NETWORK_BACKEND").ok();
let backend = select_backend(
requested.as_deref(),
std::path::Path::new("/etc/openwrt_release").is_file(),
)?;
if backend == NetworkBackend::NetworkManager {
let status = std::process::Command::new("nmcli")
.env("LC_ALL", "C")
.args(["-t", "-f", "RUNNING", "general"])
.output()
.map_err(|_| {
"OTG bridging requires NetworkManager and nmcli on this system".to_string()
})?;
if !status.status.success() || String::from_utf8_lossy(&status.stdout).trim() != "running" {
return Err("NetworkManager is not running; OTG bridging cannot manage this system's interfaces".to_string());
}
}
Ok(backend)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn automatic_selection_respects_openwrt_marker() {
assert_eq!(select_backend(None, true), Ok(NetworkBackend::Netifd));
assert_eq!(
select_backend(Some("auto"), false),
Ok(NetworkBackend::NetworkManager)
);
}
#[test]
fn explicit_selection_is_respected() {
assert_eq!(
select_backend(Some("networkmanager"), true),
Ok(NetworkBackend::NetworkManager)
);
assert_eq!(
select_backend(Some("netifd"), false),
Ok(NetworkBackend::Netifd)
);
}
#[test]
fn unknown_selection_is_not_silently_ignored() {
assert!(select_backend(Some("typo"), true).is_err());
assert!(select_backend(Some(""), false).is_err());
}
}

View File

@@ -570,6 +570,8 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
})
.is_some_and(|name| {
name == "okvm-br0" || name == config.otg_network.bridge_interface
|| (config.otg_network.bridge_interface.is_empty()
&& std::path::Path::new("/sys/class/net").join(name).join("bridge").is_dir())
});
push_otg_check(
&mut checks,

View File

@@ -3,7 +3,7 @@
use std::fs::File;
use std::io;
use std::os::fd::AsFd;
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
use std::os::fd::OwnedFd;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
@@ -31,7 +31,7 @@ use crate::video::device::VideoControlMode;
use crate::video::format::{PixelFormat, Resolution};
use crate::video::signal::SignalStatus;
#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))]
#[cfg(any(test, feature = "rkmpp"))]
#[path = "dmabuf_layout.rs"]
mod dmabuf_layout;
@@ -79,7 +79,7 @@ pub struct CaptureStream {
bridge_kind: Option<CsiBridgeKind>,
native_hdmirx_state: Option<NativeHdmirxState>,
native_hdmirx_next_state_check: Option<Instant>,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
dma_layout_bytes: Option<usize>,
}
@@ -333,7 +333,7 @@ impl CaptureStream {
mappings.push(plane_maps);
}
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
let dma_layout_bytes = PixelFormat::from_v4l2r(actual_fmt.pixelformat).and_then(|format| {
dmabuf_layout::DmaCaptureLayout {
native_hdmi: is_native_hdmirx,
@@ -364,7 +364,7 @@ impl CaptureStream {
bridge_kind: bridge.kind,
native_hdmirx_state,
native_hdmirx_next_state_check,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
dma_layout_bytes,
};
@@ -491,7 +491,7 @@ impl CaptureStream {
/// Native HDMI NV12/BGR24 and single-planar USB UVC YUYV/NV12/RGB24/MJPEG.
/// Actual EXPBUF/import support is probed separately; failure retains copy.
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
pub(crate) fn supports_rkmpp_dmabuf(&self) -> bool {
self.dma_layout_bytes.is_some_and(|minimum| {
(2..=16).contains(&self.mappings.len())
@@ -502,7 +502,7 @@ impl CaptureStream {
})
}
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
pub(crate) fn export_dmabufs(&self) -> io::Result<Vec<(OwnedFd, usize)>> {
if !self.supports_rkmpp_dmabuf() {
return Err(io::Error::new(
@@ -524,7 +524,7 @@ impl CaptureStream {
/// Run a synchronous consumer while a buffer is dequeued. QBUF occurs only
/// after the callback returns, including its error path. Consumers must end
/// hardware access before returning; see hwcodec::rkmpp_dmabuf::encode.
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
pub(crate) fn with_next_dmabuf<T>(
&mut self,
consume: impl FnOnce(usize, usize, Option<OwnedFd>) -> T,
@@ -843,17 +843,17 @@ impl CaptureStream {
}
}
#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))]
#[cfg(any(test, feature = "rkmpp"))]
struct BufferReturn<F: FnOnce() -> io::Result<()>>(Option<F>);
#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))]
#[cfg(any(test, feature = "rkmpp"))]
impl<F: FnOnce() -> io::Result<()>> BufferReturn<F> {
fn finish(mut self) -> io::Result<()> {
self.0.take().expect("capture lease already returned")()
}
}
#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))]
#[cfg(any(test, feature = "rkmpp"))]
impl<F: FnOnce() -> io::Result<()>> Drop for BufferReturn<F> {
fn drop(&mut self) {
if let Some(return_buffer) = self.0.take() {

View File

@@ -4,14 +4,14 @@ use std::io;
#[cfg(any(
test,
all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm"))
all(target_os = "linux", feature = "rkmpp")
))]
use crate::video::device::VideoControlMode;
use crate::video::signal::SignalStatus;
#[cfg(any(
test,
all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm"))
all(target_os = "linux", feature = "rkmpp")
))]
pub(crate) fn capture_recovery_status(
control_mode: VideoControlMode,

View File

@@ -17,7 +17,7 @@ pub mod video_codec;
pub mod vp8;
pub mod vp9;
#[cfg(all(feature = "desktop", any(target_arch = "aarch64", target_arch = "arm")))]
#[cfg(all(feature = "desktop", feature = "rkmpp"))]
pub mod mjpeg_rkmpp;
pub use convert::{MjpegToNv12Decoder, PixelConverter, Yuv420pBuffer};

View File

@@ -8,7 +8,7 @@ use crate::video::codec::vp8::{VP8Config, VP8Encoder};
use crate::video::codec::vp9::{VP9Config, VP9Encoder};
use crate::video::format::{PixelFormat, Resolution};
use bytes::Bytes;
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
use hwcodec::ffmpeg_hw::{
last_error_message as ffmpeg_hw_last_error, HwMjpegH26xConfig, HwMjpegH26xPipeline,
};
@@ -22,9 +22,9 @@ pub(super) struct EncoderThreadState {
pub(super) nv12_converter: Option<Nv12Converter>,
pub(super) yuv420p_converter: Option<PixelConverter>,
pub(super) encoder_needs_yuv420p: bool,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
pub(super) ffmpeg_hw_pipeline: Option<HwMjpegH26xPipeline>,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
pub(super) ffmpeg_hw_enabled: bool,
pub(super) fps: u32,
pub(super) codec: VideoEncoderType,
@@ -347,9 +347,9 @@ pub(super) fn build_encoder_state(
}
};
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
let is_rkmpp_encoder = selected_codec_name.contains("rkmpp");
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
if needs_mjpeg_decode
&& is_rkmpp_encoder
&& matches!(
@@ -389,9 +389,9 @@ pub(super) fn build_encoder_state(
nv12_converter: None,
yuv420p_converter: None,
encoder_needs_yuv420p: false,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
ffmpeg_hw_pipeline: Some(pipeline),
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
ffmpeg_hw_enabled: true,
fps: config.fps,
codec: config.output_codec,
@@ -573,9 +573,9 @@ pub(super) fn build_encoder_state(
nv12_converter,
yuv420p_converter,
encoder_needs_yuv420p: needs_yuv420p,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
ffmpeg_hw_pipeline: None,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
ffmpeg_hw_enabled: false,
fps: config.fps,
codec: config.output_codec,

View File

@@ -35,13 +35,13 @@ use super::encoder_state::{build_encoder_state, should_parallel_decode_mjpeg, En
use super::frame_mailbox::{FrameMailbox, PublishResult};
use super::mjpeg_admission::{MjpegAdmissionPolicy, MjpegAdmissionStage, MjpegDropReason};
#[cfg(all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")))]
#[cfg(all(target_os = "linux", feature = "rkmpp"))]
#[path = "dmabuf.rs"]
mod dmabuf;
#[cfg(any(
test,
all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm"))
all(target_os = "linux", feature = "rkmpp")
))]
fn rkmpp_dma_eligible(
backend: Option<EncoderBackend>,
@@ -269,7 +269,7 @@ fn spawn_mjpeg_decode_workers(
senders
}
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
use hwcodec::ffmpeg_hw::last_error_message as ffmpeg_hw_last_error;
/// Encoded video frame for distribution
@@ -726,7 +726,7 @@ impl SharedVideoPipeline {
match cmd {
PipelineCmd::SetBitrate { preset } => {
let bitrate_kbps = preset.bitrate_kbps();
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
if state.ffmpeg_hw_enabled {
if let Some(ref mut pipeline) = state.ffmpeg_hw_pipeline {
pipeline
@@ -1154,7 +1154,7 @@ impl SharedVideoPipeline {
Err(e) => return Err(e),
};
#[cfg(all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")))]
#[cfg(all(target_os = "linux", feature = "rkmpp"))]
if dmabuf::eligible(&config) {
if let Some(stream) = preopened.as_ref().filter(|s| s.supports_rkmpp_dmabuf()) {
match dmabuf::prepare(stream, &config) {
@@ -1817,7 +1817,7 @@ impl SharedVideoPipeline {
let pts_ms = self.pts_ms();
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
if state.ffmpeg_hw_enabled {
if input_format != PixelFormat::Mjpeg {
return Err(AppError::VideoError(
@@ -2527,9 +2527,9 @@ mod tests {
nv12_converter: None,
yuv420p_converter: None,
encoder_needs_yuv420p: false,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
ffmpeg_hw_pipeline: None,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
#[cfg(feature = "rkmpp")]
ffmpeg_hw_enabled: false,
fps: 30,
codec: VideoEncoderType::H264,

View File

@@ -14,6 +14,8 @@ mod platform {
use std::os::unix::fs::{FileTypeExt, MetadataExt};
use std::path::{Path, PathBuf};
// Request arguments use c_ulong on glibc and c_int on musl. Cast at the
// call sites to preserve the kernel command's low 32 bits on either libc.
const WDIOC_GETSUPPORT: libc::c_ulong = 0x8028_5700;
const WDIOC_SETOPTIONS: libc::c_ulong = 0x8004_5704;
const WDIOC_KEEPALIVE: libc::c_ulong = 0x8004_5705;
@@ -54,7 +56,7 @@ mod platform {
let supports_magic_close = unsafe {
libc::ioctl(
std::os::fd::AsRawFd::as_raw_fd(&file),
WDIOC_GETSUPPORT,
WDIOC_GETSUPPORT as _,
&mut info,
) == 0
&& info.options & WDIOF_MAGICCLOSE != 0
@@ -109,7 +111,7 @@ mod platform {
let result = unsafe {
libc::ioctl(
std::os::fd::AsRawFd::as_raw_fd(&self.file),
WDIOC_KEEPALIVE,
WDIOC_KEEPALIVE as _,
0,
)
};
@@ -125,7 +127,7 @@ mod platform {
let result = unsafe {
libc::ioctl(
std::os::fd::AsRawFd::as_raw_fd(&self.file),
WDIOC_GETTIMEOUT,
WDIOC_GETTIMEOUT as _,
&mut timeout,
)
};
@@ -152,7 +154,7 @@ mod platform {
let result = unsafe {
libc::ioctl(
std::os::fd::AsRawFd::as_raw_fd(&self.file),
WDIOC_SETOPTIONS,
WDIOC_SETOPTIONS as _,
&mut option,
)
};

View File

@@ -19,6 +19,7 @@ pub async fn health_check() -> Json<HealthResponse> {
pub struct SystemInfo {
pub version: &'static str,
pub initialized: bool,
pub openwrt_system: bool,
pub platform: PlatformCapabilities,
pub capabilities: Capabilities,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -66,6 +67,7 @@ pub async fn system_info(State(state): State<Arc<AppState>>) -> Json<SystemInfo>
Json(SystemInfo {
version: env!("CARGO_PKG_VERSION"),
initialized: config.initialized,
openwrt_system: crate::web::openwrt_system::enabled(),
platform: platform.clone(),
capabilities: Capabilities {
video: CapabilityInfo {

View File

@@ -1,6 +1,7 @@
mod audio_ws;
mod error;
mod handlers;
pub mod openwrt_system;
mod routes;
pub(crate) mod state;
mod static_files;

172
src/web/openwrt_system.rs Normal file
View File

@@ -0,0 +1,172 @@
//! Read-only shortcut to the independent OpenWrt web server. No proxy or shared auth.
use axum::{
http::{header, uri::Authority, HeaderMap, StatusCode},
response::{IntoResponse, Redirect, Response},
};
use std::{net::IpAddr, time::Duration};
use tokio::process::Command;
/// Explicit opt-in supplied by the OpenWrt image's service launcher.
pub fn enabled() -> bool {
cfg!(target_os = "linux") && std::env::var("ONE_KVM_OPENWRT").as_deref() == Ok("1")
}
fn listener_port(list: &str) -> Option<u16> {
list.split_whitespace().find_map(|listener| {
let (address, port) = listener.rsplit_once(':').unwrap_or(("0.0.0.0", listener));
let ip: IpAddr = address
.trim_start_matches('[')
.trim_end_matches(']')
.parse()
.ok()?;
if ip.is_loopback() {
return None;
}
port.parse::<u16>().ok().filter(|port| *port != 0)
})
}
fn destination(host: &str, http: &str, https: &str, redirect_https: bool) -> Option<String> {
let authority: Authority = host.parse().ok()?;
if authority.as_str().contains('@') {
return None;
}
let http = listener_port(http);
let https = listener_port(https);
let (scheme, port) = match (http, https, redirect_https) {
(_, Some(port), true) | (None, Some(port), _) => ("https", port),
(Some(port), _, _) => ("http", port),
_ => return None,
};
Some(format!(
"{scheme}://{}:{port}/cgi-bin/luci/",
authority.host()
))
}
async fn setting(name: &str) -> Result<String, ()> {
let output = tokio::time::timeout(
Duration::from_secs(2),
Command::new("/sbin/uci")
.args(["-q", "get", name])
.kill_on_drop(true)
.output(),
)
.await
.map_err(|_| ())?
.map_err(|_| ())?;
// Missing listen_http/listen_https is valid when that protocol is disabled.
if !output.status.success() {
return Ok(String::new());
}
String::from_utf8(output.stdout).map_err(|_| ())
}
pub async fn redirect_handler(headers: HeaderMap) -> Response {
if !enabled() {
return StatusCode::NOT_FOUND.into_response();
}
let Some(host) = headers
.get(header::HOST)
.and_then(|value| value.to_str().ok())
else {
return (StatusCode::BAD_REQUEST, "Missing or invalid host").into_response();
};
let Ok((http, https, redirect)) = tokio::try_join!(
setting("uhttpd.main.listen_http"),
setting("uhttpd.main.listen_https"),
setting("uhttpd.main.redirect_https")
) else {
return (
StatusCode::SERVICE_UNAVAILABLE,
"OpenWrt web configuration is unavailable",
)
.into_response();
};
match destination(
host,
&http,
&https,
matches!(redirect.trim(), "1" | "on" | "true" | "yes"),
) {
Some(url) => (
[(header::CACHE_CONTROL, "no-store")],
Redirect::temporary(&url),
)
.into_response(),
None => (
StatusCode::SERVICE_UNAVAILABLE,
"No public OpenWrt web listener is configured",
)
.into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn independent_http_port_uses_current_hostname() {
assert_eq!(
destination(
"device.test:8080",
"0.0.0.0:8081 [::]:8081",
"0.0.0.0:8444",
false
)
.as_deref(),
Some("http://device.test:8081/cgi-bin/luci/")
);
assert_eq!(
destination("192.168.1.11:9080", "0.0.0.0:9091", "", false).as_deref(),
Some("http://192.168.1.11:9091/cgi-bin/luci/")
);
}
#[test]
fn https_redirect_and_https_only_follow_openwrt() {
for http in ["0.0.0.0:8081", ""] {
assert_eq!(
destination("n1.test:8443", http, "0.0.0.0:9444", true).as_deref(),
Some("https://n1.test:9444/cgi-bin/luci/")
);
}
assert!(destination("n1.test", "", "[::]:8444", false)
.unwrap()
.starts_with("https://"));
assert!(destination("n1.test", "0.0.0.0:8081", "", true)
.unwrap()
.starts_with("http://"));
}
#[test]
fn ipv6_and_default_browser_ports() {
assert_eq!(
destination("[2001:db8::1]:8080", "[::]:8081", "", false).as_deref(),
Some("http://[2001:db8::1]:8081/cgi-bin/luci/")
);
assert!(destination("n1.test", "0.0.0.0:8081", "", false)
.unwrap()
.contains("n1.test:8081"));
}
#[test]
fn invalid_or_loopback_only_listeners_do_not_invent_a_port() {
for list in [
"",
"127.0.0.1:8081",
"[::1]:8081",
"0.0.0.0:0",
"0.0.0.0:99999",
"bad",
] {
assert_eq!(destination("n1.test:8080", list, "", false), None);
}
assert_eq!(
destination("user@n1.test:8080", "0.0.0.0:8081", "", false),
None
);
assert_eq!(
destination("https://n1.test/path", "0.0.0.0:8081", "", false),
None
);
assert_eq!(listener_port("127.0.0.1:8081 0.0.0.0:9091"), Some(9091));
}
}

View File

@@ -276,6 +276,11 @@ pub fn create_router(state: Arc<AppState>) -> Router {
.route("/terminal/ws", get(handlers::terminal::terminal_ws))
.route("/terminal/{*path}", get(handlers::terminal::terminal_proxy));
let user_routes = user_routes.route(
"/system/openwrt",
get(super::openwrt_system::redirect_handler),
);
#[cfg(unix)]
let user_routes = {
user_routes

View File

@@ -138,6 +138,7 @@ export const systemApi = {
request<{
version: string
initialized: boolean
openwrt_system: boolean
platform: PlatformCapabilities
capabilities: {
video: { available: boolean; backend?: string; reason?: string }

View File

@@ -19,7 +19,7 @@ const emits = defineEmits<{
"update:open": [open: boolean]
}>()
const isMobile = useMediaQuery("(max-width: 768px)")
const isMobile = useMediaQuery("(width < 48rem)")
const openMobile = ref(false)
const open = useVModel(props, "open", emits, {

View File

@@ -142,6 +142,7 @@ export default {
networkErrorDesc: 'Unable to connect to server. Please check your connection.',
},
nav: {
openwrtSystemManagement: 'OpenWRT System Management',
logout: 'Logout',
},
auth: {

View File

@@ -142,6 +142,7 @@ export default {
networkErrorDesc: '无法连接到服务器,请检查网络连接。',
},
nav: {
openwrtSystemManagement: 'OpenWRT 系统管理',
logout: '退出登录',
},
auth: {

View File

@@ -161,6 +161,7 @@ export interface DeviceInfoEvent {
export const useSystemStore = defineStore('system', () => {
const version = ref<string>('')
const openwrtSystem = ref(false)
const platform = ref<PlatformCapabilities | null>(null)
const capabilities = ref<SystemCapabilities | null>(null)
const diskSpace = ref<DiskSpaceInfo | null>(null)
@@ -183,6 +184,7 @@ export const useSystemStore = defineStore('system', () => {
try {
const info = await systemApi.info()
version.value = info.version
openwrtSystem.value = info.openwrt_system === true
platform.value = info.platform
capabilities.value = info.capabilities
diskSpace.value = info.disk_space ?? null
@@ -442,6 +444,7 @@ export const useSystemStore = defineStore('system', () => {
return {
version,
openwrtSystem,
platform,
capabilities,
diskSpace,

View File

@@ -196,6 +196,7 @@ const SETTINGS_SECTION_ID_SET = new Set<string>(SETTINGS_SECTION_IDS)
const navGroups = computed(() => [
{
id: 'software',
title: t('settings.software'),
items: [
{ id: 'appearance', label: t('settings.appearance'), icon: Sun },
@@ -205,6 +206,7 @@ const navGroups = computed(() => [
]
},
{
id: 'system',
title: t('settings.system'),
items: [
{ id: 'video', label: t('settings.video'), icon: Monitor },
@@ -215,6 +217,7 @@ const navGroups = computed(() => [
]
},
{
id: 'extensions',
title: t('settings.extensions'),
items: [
{ id: 'ext-ttyd', label: t('extensions.ttyd.title'), icon: Terminal },
@@ -2764,7 +2767,7 @@ watch(isWindows, () => {
<h1 class="text-xl font-semibold">{{ t('settings.title') }}</h1>
</SidebarHeader>
<SidebarContent class="px-3 pb-6 md:pb-10">
<SidebarGroup v-for="group in navGroups" :key="group.title" class="px-0 py-1">
<SidebarGroup v-for="group in navGroups" :key="group.id" class="px-0 py-1">
<SidebarGroupLabel class="uppercase">{{ group.title }}</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in group.items" :key="item.id">
@@ -2778,6 +2781,15 @@ watch(isWindows, () => {
<span>{{ item.label }}</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem v-if="systemStore.openwrtSystem && group.id === 'extensions'">
<SidebarMenuButton as-child :tooltip="t('nav.openwrtSystemManagement')" class="h-8 px-3 text-foreground">
<a href="/api/system/openwrt" target="_blank" rel="noopener noreferrer" :title="t('nav.openwrtSystemManagement')">
<Server aria-hidden="true" />
<span class="min-w-0 flex-1 truncate">{{ t('nav.openwrtSystemManagement') }}</span>
<ExternalLink class="!size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>