mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-10-27 16:00:49 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a881ed0f7 | ||
|
|
d56665371f | ||
|
|
d7239ba15c | ||
|
|
2b53c2982b | ||
|
|
1d40b34f1c | ||
|
|
f926692e2c | ||
|
|
2ebad622f0 | ||
|
|
92f4203bc2 |
@@ -11,6 +11,7 @@ categories = ["embedded", "network-programming"]
|
||||
|
||||
[features]
|
||||
default = ["desktop"]
|
||||
rkmpp = ["hwcodec/rkmpp"]
|
||||
desktop = [
|
||||
"dep:tokio",
|
||||
"dep:tokio-util",
|
||||
|
||||
127
README.en.md
127
README.en.md
@@ -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
|
||||
|
||||

|
||||
<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.
|
||||
林枫云 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" />
|
||||
|
||||
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
115
README.md
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 与缩放显示,输入模式切换、断开重连、精确落点。异常时关闭此选项恢复默认行为。
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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": [],'
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
826
src/otg/netifd_bridge.rs
Normal 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(©, &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(©).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());
|
||||
}
|
||||
}
|
||||
74
src/otg/network_backend.rs
Normal file
74
src/otg/network_backend.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
172
src/web/openwrt_system.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -142,6 +142,7 @@ export default {
|
||||
networkErrorDesc: 'Unable to connect to server. Please check your connection.',
|
||||
},
|
||||
nav: {
|
||||
openwrtSystemManagement: 'OpenWRT System Management',
|
||||
logout: 'Logout',
|
||||
},
|
||||
auth: {
|
||||
|
||||
@@ -142,6 +142,7 @@ export default {
|
||||
networkErrorDesc: '无法连接到服务器,请检查网络连接。',
|
||||
},
|
||||
nav: {
|
||||
openwrtSystemManagement: 'OpenWRT 系统管理',
|
||||
logout: '退出登录',
|
||||
},
|
||||
auth: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user