Compare commits

..

6 Commits
dev ... v261001

Author SHA1 Message Date
SilentWind
b5d13ee16e Merge pull request #317 from mofeng-git/dev
Dev
2026-10-01 23:22:59 +08:00
SilentWind
d854b6a220 Merge pull request #314 from mofeng-git/dev
Dev
2026-09-26 13:15:07 +08:00
SilentWind
0956da4ae2 Merge pull request #313 from mofeng-git/dev
Dev
2026-09-26 09:33:40 +08:00
SilentWind
9ef5011760 Merge pull request #312 from mofeng-git/dev
Dev
2026-09-21 21:50:53 +08:00
SilentWind
572bf859ba Merge pull request #309 from mofeng-git/fix/uac-native-alsa
fix: 修正 UAC 链接顺序及原生 ALSA 启动恢复
2026-09-11 08:24:02 +08:00
mofeng-git
9f34b4109b fix: 修正 UAC 链接顺序及原生 ALSA 启动恢复 2026-09-10 22:43:36 +08:00
31 changed files with 115 additions and 1720 deletions

View File

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

View File

@@ -38,23 +38,16 @@ For more information, see the [One-KVM Rust documentation](https://docs.one-kvm.
| Area | Capabilities |
|------|----------------|
| 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.
| 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) |
## Installation
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/).
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).
### Debian / Ubuntu (.deb)
@@ -109,7 +102,7 @@ One-KVM builds on many great open-source projects; a lot of time goes into testi
### Thanks
<details>
<summary><strong>Click to view the supporter list</strong></summary>
<summary><strong>Supporter list</strong></summary>
- 浩龙的电子嵌入式之路
@@ -233,108 +226,12 @@ 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
@@ -343,12 +240,12 @@ This project is supported by the following sponsors:
- **[林枫云](https://www.dkdun.cn)** — project server sponsorship
<img height="128" alt="林枫云" src="https://docs.one-kvm.cn/img/36076FEFF0898A80EBD5756D28F4076C.png" />
![林枫云](https://docs.one-kvm.cn/img/36076FEFF0898A80EBD5756D28F4076C.png)
林枫云 offers premium network routes, high-frequency game servers, and high-bandwidth servers in China and abroad.
林枫云 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
<img height="128" alt="BTBT" src="https://github.com/user-attachments/assets/c442d5f5-d72f-4a07-b9f4-400a6a0c3f1e" />
![BTBT](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.
Remote computers, consumer GPU servers, and dedicated physical machines with fully automated online delivery.

115
README.md
View File

@@ -37,20 +37,13 @@
**核心功能**
| 功能 | 能力说明 |
|------|----------|
| 视频采集与传输 | 支持 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 视频流,支持用户名与密码认证 |
具体功能可用性取决于运行平台、硬件与驱动。
|------|------|
| 视频采集 | 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) |
## 安装使用
@@ -227,100 +220,6 @@ 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,10 +18,6 @@ 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)
@@ -44,13 +40,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" "${feature_args[@]}"
cross build --release --target "$rust_target"
return
;;
esac
echo "=== Building: $rust_target (via cross with custom Dockerfile) ==="
cross build --release --target "$rust_target" "${feature_args[@]}"
cross build --release --target "$rust_target"
}
# Main

View File

@@ -268,18 +268,14 @@ build_for_platform() {
if [ "$BUILD_BINARY" = true ]; then
echo_info "Building binary with cross..."
cd "$PROJECT_ROOT"
local feature_args=()
case "$target" in
aarch64-*|armv7-*) feature_args=(--features rkmpp) ;;
esac
cross build --release --target "$target" "${feature_args[@]}"
cross build --release --target "$target"
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: ./build/build-images.sh"
echo_error "Build it first: cross build --release --target $target"
exit 1
fi

View File

@@ -0,0 +1,24 @@
# 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() && use_static {
if avcodec_lib.exists() {
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 if std::env::var_os("CARGO_FEATURE_RKMPP").is_some() {
} else {
for lib in ["rockchip_mpp", "rga"] {
if !lib_dir.join(format!("lib{lib}.a")).exists() {
panic!("missing static library: lib{lib}.a");
@@ -400,7 +400,8 @@ 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 = std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
let enable_rkmpp = matches!(target_arch.as_str(), "aarch64" | "arm")
|| 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 {
@@ -458,7 +459,8 @@ mod ffmpeg {
.unwrap();
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let enable_rkmpp = std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
let enable_rkmpp = matches!(target_arch.as_str(), "aarch64" | "arm")
|| 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(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm", 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(feature = "rkmpp"))]
#[cfg(not(any(target_arch = "aarch64", target_arch = "arm", 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(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"))]
pub mod ffmpeg_hw;
pub mod ffmpeg_ram;
#[cfg(all(
target_os = "linux",
feature = "rkmpp"
any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp")
))]
pub mod rkmpp_dmabuf;

View File

@@ -5,7 +5,6 @@
# <site_name>/v1/channels.json
# <site_name>/v1/releases.json
# <site_name>/v1/bin/<version>/one-kvm-<triple>
# 默认从 GitHub Release 下载 deb,提取二进制;也支持本地二进制输入。
#
set -euo pipefail
@@ -18,11 +17,6 @@ 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=""
@@ -38,17 +32,10 @@ declare -a NOTES=()
usage() {
cat <<'EOF'
Usage:
./scripts/build-update-site.sh [options]
./scripts/build-update-site.sh --version <x.x.x> [options]
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 输入指定时校验版本一致
Required:
--version <x.x.x> Release 版本号(如 0.1.10)
Artifact input (二选一,可混用):
--artifacts-dir <dir> 自动扫描目录中的标准文件名:
@@ -63,8 +50,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> 发布时间,默认 GitHub 发布时间(本地输入为当前时间)
--note <text> 发布说明,可重复;指定后覆盖 GitHub 说明
--published-at <RFC3339> 发布时间,默认当前 UTC 时间
--note <text> 发布说明,可重复传入多次
Output options:
--site-name <name> 打包根目录名,默认 one-kvm-update
@@ -75,12 +62,6 @@ 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 \
@@ -123,26 +104,7 @@ 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
@@ -161,7 +123,6 @@ while [[ $# -gt 0 ]]; do
;;
--published-at)
PUBLISHED_AT="${2:-}"
PUBLISHED_AT_SET=1
shift 2
;;
--note)
@@ -214,128 +175,6 @@ 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}"
@@ -381,6 +220,9 @@ 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}"
@@ -426,7 +268,7 @@ RELEASES_FILE="${V1_DIR}/releases.json"
echo ' {'
echo " \"version\": \"${VERSION}\","
echo " \"channel\": \"${RELEASE_CHANNEL}\","
echo " \"published_at\": \"$(json_escape "$PUBLISHED_AT")\","
echo " \"published_at\": \"${PUBLISHED_AT}\","
if [[ ${#NOTES[@]} -eq 0 ]]; then
echo ' "notes": [],'

View File

@@ -8,8 +8,6 @@ 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";
@@ -106,24 +104,11 @@ impl TransactionProfiles {
#[derive(Debug, Clone)]
pub struct NetworkBridgeRuntime {
journal: RuntimeJournal,
}
#[derive(Debug, Clone)]
enum RuntimeJournal {
NetworkManager(BridgeJournal),
Netifd(netifd_bridge::Journal),
journal: BridgeJournal,
}
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")?;
@@ -336,23 +321,16 @@ impl NetworkBridgeRuntime {
})();
match result {
Ok(_address) => Ok(Self {
journal: RuntimeJournal::NetworkManager(journal),
}),
Ok(_address) => Ok(Self { journal }),
Err(error) => Err(restore_or_combine(&journal, error)),
}
}
pub fn deactivate(&self) -> Result<()> {
// 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),
}
restore_from_journal(&self.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(()),
@@ -362,7 +340,6 @@ 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!(
@@ -377,92 +354,10 @@ 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(
@@ -545,11 +440,10 @@ 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 Ethernet interface or supported bridge is available for OTG bridging"
"No connected physical NetworkManager Ethernet interface is available for OTG bridging"
.to_string(),
)
});
@@ -557,15 +451,10 @@ fn select_bridge_candidate<'a>(
interfaces
.iter()
.find(|item| item.name == requested && item.bridge_supported)
.find(|item| item.name == requested)
.ok_or_else(|| {
AppError::Config(format!(
"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")
"Network interface {requested} is not a connected physical NetworkManager Ethernet interface"
))
})
}
@@ -870,7 +759,7 @@ fn connection_value(connection: &str, property: &str) -> Result<String> {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub(super) fn first_ipv4_address(interface: &str) -> Option<String> {
fn first_ipv4_address(interface: &str) -> Option<String> {
ipv4_addresses(interface).into_iter().next()
}
@@ -894,7 +783,7 @@ fn ipv4_addresses(interface: &str) -> Vec<String> {
.collect()
}
pub(super) fn default_route(interface: &str) -> Option<String> {
fn default_route(interface: &str) -> Option<String> {
let output = Command::new("ip")
.args(["-4", "route", "show", "default", "dev", interface])
.output()
@@ -912,7 +801,7 @@ fn gateway_from_route(route: &str) -> Option<&str> {
.find_map(|part| (part[0] == "via").then_some(part[1]))
}
pub(super) fn ensure_command(name: &str) -> Result<()> {
fn ensure_command(name: &str) -> Result<()> {
let status = Command::new(name).arg("--version").output();
if status.is_err() {
return Err(AppError::BadRequest(format!(
@@ -926,7 +815,7 @@ fn run_nmcli(args: &[&str]) -> Result<Output> {
run_command("nmcli", args)
}
pub(super) fn run_command(command: &str, args: &[&str]) -> Result<Output> {
fn run_command(command: &str, args: &[&str]) -> Result<Output> {
let output = Command::new(command)
.env("LC_ALL", "C")
.args(args)
@@ -953,49 +842,6 @@ pub(super) 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,
@@ -1148,6 +994,6 @@ mod tests {
let error = select_bridge_candidate(&[], "").unwrap_err();
assert!(matches!(error, AppError::Config(_)));
assert!(error.to_string().contains("connected Ethernet"));
assert!(error.to_string().contains("connected physical"));
}
}

View File

@@ -14,10 +14,6 @@ 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)]

View File

@@ -1,826 +0,0 @@
//! 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

@@ -1,74 +0,0 @@
//! 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,8 +570,6 @@ 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(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
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, feature = "rkmpp"))]
#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))]
#[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(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
dma_layout_bytes: Option<usize>,
}
@@ -333,7 +333,7 @@ impl CaptureStream {
mappings.push(plane_maps);
}
#[cfg(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
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(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
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(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
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(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
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(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
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, feature = "rkmpp"))]
#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))]
struct BufferReturn<F: FnOnce() -> io::Result<()>>(Option<F>);
#[cfg(any(test, feature = "rkmpp"))]
#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))]
impl<F: FnOnce() -> io::Result<()>> BufferReturn<F> {
fn finish(mut self) -> io::Result<()> {
self.0.take().expect("capture lease already returned")()
}
}
#[cfg(any(test, feature = "rkmpp"))]
#[cfg(any(test, target_arch = "aarch64", target_arch = "arm"))]
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", feature = "rkmpp")
all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm"))
))]
use crate::video::device::VideoControlMode;
use crate::video::signal::SignalStatus;
#[cfg(any(
test,
all(target_os = "linux", feature = "rkmpp")
all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm"))
))]
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", feature = "rkmpp"))]
#[cfg(all(feature = "desktop", any(target_arch = "aarch64", target_arch = "arm")))]
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(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
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(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
pub(super) ffmpeg_hw_pipeline: Option<HwMjpegH26xPipeline>,
#[cfg(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
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(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
let is_rkmpp_encoder = selected_codec_name.contains("rkmpp");
#[cfg(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
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(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
ffmpeg_hw_pipeline: Some(pipeline),
#[cfg(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
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(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
ffmpeg_hw_pipeline: None,
#[cfg(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
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", feature = "rkmpp"))]
#[cfg(all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")))]
#[path = "dmabuf.rs"]
mod dmabuf;
#[cfg(any(
test,
all(target_os = "linux", feature = "rkmpp")
all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm"))
))]
fn rkmpp_dma_eligible(
backend: Option<EncoderBackend>,
@@ -269,7 +269,7 @@ fn spawn_mjpeg_decode_workers(
senders
}
#[cfg(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
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(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
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", feature = "rkmpp"))]
#[cfg(all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")))]
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(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
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(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
ffmpeg_hw_pipeline: None,
#[cfg(feature = "rkmpp")]
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
ffmpeg_hw_enabled: false,
fps: 30,
codec: VideoEncoderType::H264,

View File

@@ -14,8 +14,6 @@ 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;
@@ -56,7 +54,7 @@ mod platform {
let supports_magic_close = unsafe {
libc::ioctl(
std::os::fd::AsRawFd::as_raw_fd(&file),
WDIOC_GETSUPPORT as _,
WDIOC_GETSUPPORT,
&mut info,
) == 0
&& info.options & WDIOF_MAGICCLOSE != 0
@@ -111,7 +109,7 @@ mod platform {
let result = unsafe {
libc::ioctl(
std::os::fd::AsRawFd::as_raw_fd(&self.file),
WDIOC_KEEPALIVE as _,
WDIOC_KEEPALIVE,
0,
)
};
@@ -127,7 +125,7 @@ mod platform {
let result = unsafe {
libc::ioctl(
std::os::fd::AsRawFd::as_raw_fd(&self.file),
WDIOC_GETTIMEOUT as _,
WDIOC_GETTIMEOUT,
&mut timeout,
)
};
@@ -154,7 +152,7 @@ mod platform {
let result = unsafe {
libc::ioctl(
std::os::fd::AsRawFd::as_raw_fd(&self.file),
WDIOC_SETOPTIONS as _,
WDIOC_SETOPTIONS,
&mut option,
)
};

View File

@@ -19,7 +19,6 @@ 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")]
@@ -67,7 +66,6 @@ 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,7 +1,6 @@
mod audio_ws;
mod error;
mod handlers;
pub mod openwrt_system;
mod routes;
pub(crate) mod state;
mod static_files;

View File

@@ -1,172 +0,0 @@
//! 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,11 +276,6 @@ 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,7 +138,6 @@ 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("(width < 48rem)")
const isMobile = useMediaQuery("(max-width: 768px)")
const openMobile = ref(false)
const open = useVModel(props, "open", emits, {

View File

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

View File

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

View File

@@ -161,7 +161,6 @@ 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)
@@ -184,7 +183,6 @@ 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
@@ -444,7 +442,6 @@ export const useSystemStore = defineStore('system', () => {
return {
version,
openwrtSystem,
platform,
capabilities,
diskSpace,

View File

@@ -196,7 +196,6 @@ 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 },
@@ -206,7 +205,6 @@ const navGroups = computed(() => [
]
},
{
id: 'system',
title: t('settings.system'),
items: [
{ id: 'video', label: t('settings.video'), icon: Monitor },
@@ -217,7 +215,6 @@ const navGroups = computed(() => [
]
},
{
id: 'extensions',
title: t('settings.extensions'),
items: [
{ id: 'ext-ttyd', label: t('extensions.ttyd.title'), icon: Terminal },
@@ -2767,7 +2764,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.id" class="px-0 py-1">
<SidebarGroup v-for="group in navGroups" :key="group.title" class="px-0 py-1">
<SidebarGroupLabel class="uppercase">{{ group.title }}</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in group.items" :key="item.id">
@@ -2781,15 +2778,6 @@ 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>