mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-10 01:49:00 -06:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8553cf40b | ||
|
|
bb7194eb6c | ||
|
|
44c420e28b | ||
|
|
4b54fc4768 | ||
|
|
02242c1908 | ||
|
|
509a22f6bd | ||
|
|
5572e1de35 | ||
|
|
d0ff328db8 | ||
|
|
ff45b46c49 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -36,3 +36,5 @@ kvmapp/kvm_system/kvm_system
|
||||
|
||||
# Release packages assembled by scripts/package.sh
|
||||
/build/
|
||||
kvmapp/server
|
||||
kvmapp/version
|
||||
|
||||
103
CHANGELOG.md
103
CHANGELOG.md
@@ -1,3 +1,106 @@
|
||||
## 2.5.4 (2026-11-10)
|
||||
|
||||
### Features
|
||||
|
||||
* Reworked the storage page around the NanoKVM Go file manager: the storage menu is now a two-card picker, "Disk mount" and "File sharing", that opens dedicated windows, and file sharing became a real file manager for the shared folder with list and icon views (remembered per browser), sorting by name, size, type and modified time, search in the current folder, breadcrumbs, inline folder expansion, new folder, download, delete, and uploads from the picker or by drag and drop, including folder upload with nested directories (thanks to [@liangzhuohua](https://github.com/liangzhuohua))
|
||||
* Kept the two storage windows openable while the virtual disk is switched off, and let a disabled write action name the steps that unlock it: eject the virtual USB drive on the target machine and turn off the Virtual Disk switch in Settings > Device
|
||||
* Removed the manual resolution setting: the video now follows the HDMI input resolution automatically, and the pointer and input-region code use the size of the frame the browser is actually showing
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fixed an application update leaving the page black or answering 404: service operations are serialized with an init-script lock, the migration finishes before services start, the startup script is installed atomically, recursive restarts are gone and the migration marker is kept until every required change succeeds
|
||||
* Fixed video recovery after an application update leaving the encoder and VPSS in a broken state: video ownership is claimed before orphan VENC/VPSS resources are cleared, and the recovery SYS cleanup always runs to completion
|
||||
* Fixed an updated web interface being hidden by a cached document: `index.html` now revalidates on every load and content-hashed assets are cached as immutable only while the file exists, so a stale document or a cached 404 is never pinned in the browser
|
||||
* Fixed keyboard input being stolen from text fields: forwarding now skips events aimed at an input, textarea, select or contenteditable element and releases held keys when focus moves into one, so typing and pasting in the new dialogs works
|
||||
* Fixed `/data` staying read-only while no medium was attached after a failed remount or an exFAT error; the media state poll now heals it and writable opens and transfers retry before failing
|
||||
* Fixed the disk mount window reporting transfers it did not start by keeping the media job in the file manager
|
||||
|
||||
### Performance
|
||||
|
||||
* Reworked H.264 and MJPEG capture on the Sophgo SDK: each codec gets its own capture session and lock with encoder backpressure, frame ownership is explicit, capture stops as soon as the last viewer leaves, and the bundled video libraries were rebuilt
|
||||
|
||||
### UI Improvements
|
||||
|
||||
* Made a menu picker that opens its own window close without collapsing the menu bar
|
||||
|
||||
### Localization
|
||||
|
||||
* Translated the new storage page and file manager strings for all supported locales
|
||||
|
||||
### Chores
|
||||
|
||||
* Added a `POST /api/storage/media/files/mkdir` endpoint for recreating a folder tree during upload, with the same permission model as upload and delete, path and symlinked-parent validation, and a distinct error when the name is already taken by a file
|
||||
* Rebuilt the bundled `libkvm.so` and `libkvm_mmf.so` against the Sophgo SDK and refreshed the build and deployment instructions
|
||||
* Added unit tests for media file creation, read-only `/data` healing and the static file cache headers
|
||||
|
||||
## 2.5.3 (2026-11-03)
|
||||
|
||||
### Features
|
||||
|
||||
* Replaced the desktop image menu and the download page with a storage page that lists the media the emulated USB disk can carry, mounts and ejects them, and browses the files inside a medium over the same gadget, so the two machines exchange files without going through the terminal
|
||||
* Added uploads, downloads and deletes to the storage page, with search, sorting, folder deletion behind a confirmation, one transfer display for image and file transfers, cancellable uploads, a free space preflight and a checksum mismatch report
|
||||
* Browsed only media whose filesystem this device can read, so an ISO 9660 image is still served to the target machine but cannot be listed here, and limited CD-ROM mode to ISO 9660 and UDF images
|
||||
* Made the shared folder follow the target machine: while it owns the data partition the listing refreshes on a timer after dropping the dentry and inode caches, so a file the target machine wrote shows up within a second of its own writeback
|
||||
* Added a fine-grained permission system on top of the `admin` and `user` roles: administrators keep implicit full access, while every other account receives an explicit set of grants covering the console, power, media, terminal, device, network, updates, MCP, Tailscale and AI features, with the groups, risk marks and implied grants shown in a permission editor; mounting a medium needs the virtual disk, and the storage page names the account that has to enable it when the current account cannot
|
||||
* Added a view-only console session for an account that may watch but not send input: the pointer and keyboard capture, the mouse menu and the input region editor follow the grant, pages, menus, settings tabs and storage actions render only what the account is allowed to use, and an account without console access lands on a page offering the sections its grants open
|
||||
* Changed permission handling so an open session picks up the new grants without ending the login: a change that invalidates a cached capability drops the long-lived connection with a dedicated close code so the page reconnects, and only disabling an account ends the login
|
||||
* Added the choice of regenerating the device-wide MCP API key when an account loses the MCP grant, instead of rotating the key silently and disconnecting every MCP client
|
||||
* Kept the pre-2.6 image endpoints working as a compatibility layer over the new media API, so existing clients and scripts keep working
|
||||
* Treated UDF images, not only ISO 9660 ones, as optical media, and accepted uploads by extension (`.iso`/`.img`), leaving an unusable image to fail when it is mounted instead of while it is stored
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fixed HID input being lost after a medium was mounted or the gadget was otherwise reset, by reopening the HID devices with a retry instead of a single attempt that could fail while the new nodes were still coming up
|
||||
* Fixed `/data` staying read-only after a failed gadget script or a network switch
|
||||
* Fixed the settings menu icon sitting a few pixels below the other menu icons, and centered the dialog tab icons in their box
|
||||
* Fixed Chinese and Japanese punctuation being drawn by a Latin face in tooltips and dialogs, which pushed the ideographic comma and full stop above the baseline
|
||||
|
||||
### UI Improvements
|
||||
|
||||
* Gave the shared folder, ISO and IMG entries their own glyphs and picked file icons from the extension, so a listing can be read at a glance
|
||||
* Listed administrators above regular accounts and gave their rows a lighter card
|
||||
* Moved the PicoClaw chat composer to an antd text area and replaced the Radix scroll area with the native scroll container, so the settings dialog and the shortcut lists scroll like the rest of the app
|
||||
|
||||
### Localization
|
||||
|
||||
* Translated every string the storage page, the permission editor, the view-only console and the MCP key rotation add for all 24 supported locales
|
||||
* Removed the keys the retired image menu and download page left behind
|
||||
|
||||
### Security
|
||||
|
||||
* Mounted images are opened through loop devices that refuse symlinks, and files inside a medium are opened without following links
|
||||
|
||||
### Chores
|
||||
|
||||
* Removed the desktop image menu, the download page and their web client code
|
||||
* Dropped the `@radix-ui/react-scroll-area` dependency
|
||||
* Documented the permission catalog, the split between mounting media and writing the library, and the grants that reach the whole device in `server/README.md` and `server/README_ZH.md`
|
||||
|
||||
## 2.5.2 (2026-09-29)
|
||||
|
||||
### Features
|
||||
|
||||
* Added a one-writer policy for the emulated USB disk and the `/data` partition: `/data` is mounted read-only from boot while the virtual disk serves the raw partition to the target machine, enabling the disk remounts it read-only and verifies the state before the target machine can write it, refusing to expose the disk when the remount fails, and releasing a medium hands the raw partition back to the target machine the same way
|
||||
* Added confirmation before a mounted medium and the virtual disk take the shared partition from each other, including a warning that a running image transfer is cancelled and that `/data` becomes read-only on this device
|
||||
* Added cancellation of a running image transfer before the partition handover, showed the transfer state in the image list and disabled the actions that cannot run while a medium is mounted, with a hint explaining why
|
||||
* Added names for the mounted medium and the emulated USB device taken from the image file, truncated to the SCSI inquiry product field and the USB product string and restored on unmount
|
||||
* Added streaming validation of uploaded images: ISO 9660 and UDF volume descriptors and raw disk images (GPT, APFS, HFS+, MBR) are accepted and anything else is rejected in seconds instead of after the whole transfer, with a free space check that keeps a 128 MiB reserve before any byte is received
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fixed mass storage mounts staying writable from the target machine: every mounted image is now served read-only, with the flag set before the LUN is attached, so the target machine can neither write to nor format it
|
||||
* Fixed the LUN flags surviving a gadget restart, which brought the shared partition back to the target machine read-only or as an optical drive after a medium had been mounted
|
||||
* Fixed the virtual disk switch staying on but unchangeable while a medium was mounted; the disk now reports itself on only while the LUN really carries the `/data` partition
|
||||
* Fixed the on-screen keyboard crashing the page with "Minified React error #130" as soon as it was opened, by importing the `react-simple-keyboard` component through its named export, which the CommonJS interop under Vite 8 no longer resolves from a default import
|
||||
|
||||
### Localization
|
||||
|
||||
* Translated the new virtual disk and image mounting strings for every locale
|
||||
|
||||
### Chores
|
||||
|
||||
* Added unit tests for the upload header check, the free space preflight and the image name truncation
|
||||
|
||||
## 2.5.1 (2026-09-09)
|
||||
|
||||
### Features
|
||||
|
||||
2
Makefile
2
Makefile
@@ -18,7 +18,7 @@ DOCKER_RUN_BASE := docker run -e UID=$(UID) -e GID=$(GID) -v $(PWD):/home/build/
|
||||
DOCKER_BUILD_ARGS := --build-arg DOCKER_UID=$(UID) --build-arg DOCKER_GID=$(GID)
|
||||
|
||||
# Build commands
|
||||
GO_BUILD_CMD := cd /home/build/NanoKVM/server && go mod tidy && CGO_ENABLED=1 GOOS=linux GOARCH=riscv64 CC=riscv64-unknown-linux-musl-gcc CGO_CFLAGS="-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d" go build
|
||||
GO_BUILD_CMD := cd /home/build/NanoKVM/server && ./build.sh
|
||||
SUPPORT_BUILD_CMD := . ./home/build/MaixCDK/bin/activate && cd /home/build/NanoKVM/support/sg2002 && ./build kvm_system && ./build kvm_system add_to_kvmapp
|
||||
VISION_BUILD_CMD := . ./home/build/MaixCDK/bin/activate && cd /home/build/NanoKVM/support/sg2002 && ./build kvm_vision && ./build kvm_vision add_to_kvmapp
|
||||
RELEASE_BUILD_CMD := /home/build/NanoKVM/scripts/build-in-container.sh
|
||||
|
||||
@@ -97,7 +97,7 @@ The repository ships a [Dev Container](https://containers.dev) setup (`.devconta
|
||||
|
||||
- Requirements: Docker plus an editor or CLI with dev container support (e.g. the VS Code "Dev Containers" extension, or `devcontainer up`).
|
||||
- On creation the container prepares itself automatically: `support/sg2002/build update_lib` (resyncs MaixCDK components with the checkout) and `pnpm install` for the frontend.
|
||||
- Backend: run `server/build.sh` for a release-equivalent binary (BoringCrypto + `dl_lib` RPATH). `make app` builds a plain development binary without those, so prefer `server/build.sh` whenever the result should match a release.
|
||||
- Backend: inside the container, run `./build.sh` from `server/`. On the host, `make app` runs the same script in Docker. Both use the prebuilt libraries in `server/dl_lib/`, enable the BoringCrypto build option, and set the `dl_lib` RPATH.
|
||||
- Support layer: `cd support/sg2002 && ./build kvm_system` (the MaixCDK virtualenv is part of the container environment).
|
||||
- Frontend: `cd web && pnpm dev` (VS Code forwards port 3001 automatically; other clients may need to map it themselves); see [web/README.md](web/README.md) for pointing it at a device.
|
||||
- The base image `ghcr.io/sipeed/nanokvm-builder:latest` is x86-64 only; on Apple Silicon it runs under emulation (works, but C builds are slow). You can also build the image locally first (`make builder-image`) and point the `BASE_IMAGE` build arg at it (e.g. via `build.args` in `devcontainer.json`). On Linux hosts the first container creation also takes a while: the container user is remapped to your uid, which re-chowns its home directory once.
|
||||
|
||||
@@ -35,7 +35,14 @@ then
|
||||
if [ -e /dev/mmcblk0p3 ]
|
||||
then
|
||||
mkdir -p /data
|
||||
mount /dev/mmcblk0p3 /data
|
||||
# The partition is the virtual disk. While it is shared as a writable
|
||||
# disk the host is the only writer, so mount it read-only here.
|
||||
if [ -e /boot/usb.disk0 ] && [ ! -e /boot/usb.disk0.ro ] && [ ! -s /boot/usb.disk0 ]
|
||||
then
|
||||
mount -o ro /dev/mmcblk0p3 /data
|
||||
else
|
||||
mount /dev/mmcblk0p3 /data
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "OK"
|
||||
|
||||
@@ -134,11 +134,15 @@ start_usb_dev(){
|
||||
mkdir functions/mass_storage.disk0
|
||||
ln -s functions/mass_storage.disk0 configs/c.1/
|
||||
echo 1 > functions/mass_storage.disk0/lun.0/removable
|
||||
# The LUN flags survive a restart of this script, so they are always set:
|
||||
# a mounted image leaves them read-only and in CD-ROM mode.
|
||||
if [ -e /boot/usb.disk0.ro ]
|
||||
then
|
||||
echo 1 > functions/mass_storage.disk0/lun.0/ro
|
||||
echo 0 > functions/mass_storage.disk0/lun.0/cdrom
|
||||
else
|
||||
echo 0 > functions/mass_storage.disk0/lun.0/ro
|
||||
fi
|
||||
echo 0 > functions/mass_storage.disk0/lun.0/cdrom
|
||||
echo "NanoKVM USB Mass Storage0520" > functions/mass_storage.disk0/lun.0/inquiry_string
|
||||
disk=$(cat /boot/usb.disk0)
|
||||
if [ -z "${disk}" ]
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#!/bin/sh
|
||||
# nanokvm Rev3.1
|
||||
|
||||
exec 9>/run/nanokvm-service.lock || exit 1
|
||||
flock -n 9 || { echo "NanoKVM service operation already in progress" >&2; exit 1; }
|
||||
|
||||
wait_for_exit() {
|
||||
process="$1"
|
||||
timeout="$2"
|
||||
@@ -34,43 +37,42 @@ stop_process() {
|
||||
fi
|
||||
|
||||
killall -KILL "$process" 2>/dev/null || true
|
||||
wait_for_exit "$process" 2 || true
|
||||
wait_for_exit "$process" 2 || {
|
||||
echo "Failed to stop $process" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
stop_services() {
|
||||
# The server owns VI/VPSS. Let it release MMF before stopping kvm_system.
|
||||
stop_process NanoKVM-Server
|
||||
stop_process kvm_system
|
||||
rm -rf /tmp/kvm_system /tmp/server
|
||||
stop_process NanoKVM-Server || return 1
|
||||
stop_process kvm_system || return 1
|
||||
}
|
||||
|
||||
start_services() {
|
||||
cp -r /kvmapp/kvm_system /tmp/
|
||||
if pidof NanoKVM-Server >/dev/null 2>&1 && pidof kvm_system >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if pidof NanoKVM-Server >/dev/null 2>&1 || pidof kvm_system >/dev/null 2>&1; then
|
||||
echo "NanoKVM is partially running; use restart" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
rm -rf /tmp/kvm_system /tmp/server || return 1
|
||||
cp -r /kvmapp/kvm_system /tmp/ || return 1
|
||||
if [ -f /kvmapp/kvm_new_app ]; then
|
||||
touch /tmp/.nanokvm_migrating
|
||||
/tmp/kvm_system/kvm_system &
|
||||
|
||||
# The legacy migration invokes this init script recursively and starts
|
||||
# the server itself. Wait for that single migration path to finish.
|
||||
migration_wait=0
|
||||
while [ "$migration_wait" -lt 30 ]; do
|
||||
if [ ! -f /kvmapp/kvm_new_app ] && pidof NanoKVM-Server >/dev/null 2>&1; then
|
||||
rm -f /tmp/.nanokvm_migrating
|
||||
return
|
||||
fi
|
||||
sleep 1
|
||||
migration_wait=$((migration_wait + 1))
|
||||
done
|
||||
rm -f /tmp/.nanokvm_migrating
|
||||
else
|
||||
/tmp/kvm_system/kvm_system &
|
||||
/tmp/kvm_system/kvm_system --migrate 9>&-
|
||||
case "$?" in
|
||||
0) ;;
|
||||
2) sync; reboot; return "$?" ;;
|
||||
*) echo "NanoKVM migration failed" >&2; return 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if ! pidof NanoKVM-Server >/dev/null 2>&1; then
|
||||
rm -rf /tmp/server
|
||||
cp -r /kvmapp/server /tmp/
|
||||
/tmp/server/NanoKVM-Server &
|
||||
fi
|
||||
cp -r /kvmapp/server /tmp/ || return 1
|
||||
# Daemons must not inherit the service-operation lock.
|
||||
/tmp/kvm_system/kvm_system 9>&- &
|
||||
/tmp/server/NanoKVM-Server 9>&- &
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
@@ -118,20 +120,17 @@ case "$1" in
|
||||
fi
|
||||
|
||||
# Start services
|
||||
start_services
|
||||
start_services || exit 1
|
||||
;;
|
||||
|
||||
stop)
|
||||
stop_services
|
||||
stop_services || exit 1
|
||||
echo "OK"
|
||||
;;
|
||||
|
||||
restart)
|
||||
if [ -f /tmp/.nanokvm_migrating ]; then
|
||||
exit 0
|
||||
fi
|
||||
stop_services
|
||||
start_services
|
||||
stop_services || exit 1
|
||||
start_services || exit 1
|
||||
|
||||
sync
|
||||
echo "OK"
|
||||
|
||||
@@ -81,22 +81,27 @@ manual download and does not change the on-device `latest.json` contract.
|
||||
| `server/web/` | `web/dist/` |
|
||||
| `kvm_system/kvm_system` | `support/sg2002` `build kvm_system` |
|
||||
| `system/tool/` | prebuilt binaries in `tools/nanokvm_update_edid/` |
|
||||
| `kvm/` | default runtime state (resolution, fps, quality) |
|
||||
| `kvm/` | default runtime state (input dimensions, fps, quality) |
|
||||
| everything else | tracked `kvmapp/` |
|
||||
|
||||
Note that `kvm/` ships default runtime state, and `install.go` replaces
|
||||
`/kvmapp` wholesale, so an update resets those values on the device. That is
|
||||
long-standing behaviour, not something these scripts introduce.
|
||||
|
||||
## The two copies of libkvm.so
|
||||
## Video library linking
|
||||
|
||||
`NanoKVM-Server` is cgo-linked against the **tracked** `server/dl_lib/libkvm.so`
|
||||
(`server/common/kvm_vision.go`: `-L../dl_lib -lkvm`), but the package ships the
|
||||
library the `kvm_vision` build just produced. Those are two different files, and
|
||||
the tracked one can lag well behind — published releases have shipped this
|
||||
mismatch for a long time.
|
||||
`make app` and `server/build.sh` compile the server using the prebuilt libraries
|
||||
in `server/dl_lib/`. They do not rebuild the video libraries. The cgo directive
|
||||
in `server/common/kvm_vision.go` links against `libkvm.so`, which depends on
|
||||
`libkvm_mmf.so`.
|
||||
|
||||
Because a symbol the binary imports could in principle be absent from the
|
||||
shipped library, and that would only surface as a crash on a real device,
|
||||
`build-in-container.sh` checks the shipped `libkvm.so` still exports every
|
||||
symbol `NanoKVM-Server` actually imports from it, and fails the build otherwise.
|
||||
`make vision` rebuilds the video libraries and stages them in
|
||||
`kvmapp/server/dl_lib/`; `make release-build` does this as part of the full build.
|
||||
After changing library interfaces, update both prebuilt files in
|
||||
`server/dl_lib/` from the same build. The tracked directory also supplies the
|
||||
SDK runtime libraries used by the video build and backfills libraries that
|
||||
are absent from the staged directory.
|
||||
|
||||
`build-in-container.sh` checks that the staged `libkvm.so` exports every KVM
|
||||
symbol imported by the server. See the [server instructions](../server/README.md#compile--deploy)
|
||||
for manual compilation and deployment.
|
||||
|
||||
@@ -55,13 +55,8 @@ fi
|
||||
# shellcheck disable=SC1091
|
||||
. "$MAIXCDK_PATH/bin/activate"
|
||||
|
||||
echo "::group::support: sync MaixCDK components"
|
||||
# Each support build synchronizes the repository components before compiling.
|
||||
cd "$NANOKVM_PATH/support/sg2002"
|
||||
# The image bakes support/sg2002/additional/ into MaixCDK's components at image
|
||||
# build time, and ./build only re-copies them when components/kvm is missing.
|
||||
# Refresh unconditionally so a cached image never yields stale libraries.
|
||||
./build update_lib
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::support: build kvm_system"
|
||||
./build kvm_system
|
||||
@@ -100,11 +95,8 @@ if [ "$missing" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# NanoKVM-Server is cgo-linked against the tracked server/dl_lib/libkvm.so
|
||||
# (server/common/kvm_vision.go: -L../dl_lib -lkvm), but the package ships the
|
||||
# freshly built one from kvm_vision. Those are different files, so confirm the
|
||||
# shipped library still exports every symbol the binary actually imports from
|
||||
# it. Without this a dropped symbol only shows up as a crash on a real device.
|
||||
# Confirm the shipped video libraries provide every KVM symbol imported by
|
||||
# the server, including the frame ownership API.
|
||||
echo "[INFO] verifying shipped libkvm.so satisfies NanoKVM-Server"
|
||||
if ! command -v readelf >/dev/null 2>&1; then
|
||||
echo "[WARN] readelf unavailable, skipping ABI check"
|
||||
@@ -120,13 +112,10 @@ else
|
||||
| sed 's/@.*//' | LC_ALL=C sort -u
|
||||
}
|
||||
|
||||
linked_lib="$NANOKVM_PATH/server/dl_lib/libkvm.so"
|
||||
shipped_lib="$NANOKVM_PATH/kvmapp/server/dl_lib/libkvm.so"
|
||||
server_bin="$NANOKVM_PATH/server/NanoKVM-Server"
|
||||
|
||||
# Symbols the binary imports that the link-time library provides, i.e. the
|
||||
# ones libkvm.so is actually responsible for at runtime.
|
||||
needed=$(comm -12 <(imported "$server_bin") <(exported "$linked_lib"))
|
||||
needed=$(imported "$server_bin" | awk '/^(kvmv_|free_kvmv_data$|free_all_kvmv_data$|set_h264_gop$|set_frame_detact$|set_venc_auto_recyc$)/')
|
||||
|
||||
if [ -z "$needed" ]; then
|
||||
echo "[WARN] no libkvm symbols resolved; skipping ABI check"
|
||||
|
||||
@@ -66,8 +66,68 @@ turn:
|
||||
|
||||
NanoKVM uses two device-wide roles:
|
||||
|
||||
- `admin`: KVM access plus user, system, network, update, storage, terminal, script, MCP, and PicoClaw administration.
|
||||
- `user`: KVM video, keyboard, mouse, paste, power/reset, and Wake-on-LAN access.
|
||||
- `admin`: everything below, plus user management.
|
||||
- `user`: only what the account is granted.
|
||||
|
||||
A grant names an action on one party, and the catalog the server serves to the account editor
|
||||
carries its group, its risk, and the grants it implies:
|
||||
|
||||
| Group | Grants |
|
||||
| --- | --- |
|
||||
| console (this session) | `console.view`, `console.control` |
|
||||
| target machine | `power`, `medium.attach` |
|
||||
| media library (this device) | `library.read`, `library.write` |
|
||||
| this device | `terminal`, `device`, `network`, `update` |
|
||||
| services | `mcp`, `tailscale`, `ai` |
|
||||
|
||||
Implied grants are declared once, in the catalog, and the server resolves them before answering any
|
||||
request: `console.control` implies `console.view`, both `medium.attach` and `library.write` imply
|
||||
`library.read`, and every grant whose controls live in the console menu — `power`, `device`,
|
||||
`network`, `update`, `mcp`, `tailscale` and `ai` — implies `console.view` too, because no page
|
||||
outside the console renders them. Consequences worth knowing:
|
||||
|
||||
- An account can hold no console access at all. The web app then lands on a page that offers the
|
||||
sections the account may use (the media library, the terminal) instead of a console it cannot
|
||||
open. `library.read` is all that media access needs, because the other two media grants imply it,
|
||||
and only `terminal` and the media grants can stand on their own pages in the first place.
|
||||
- The media grants split along the trust boundary. `medium.attach` mounts or ejects a medium on the
|
||||
target machine and writes into the shared partition the target reads; `library.write` changes what
|
||||
the library keeps (uploads, deletes, the files inside an image); `library.read` lists and downloads.
|
||||
The file endpoints serve an image and the shared partition through one path, so the route admits
|
||||
either grant and each request is checked against the medium type it names. Every `.iso` and
|
||||
`.img` on the partition is a library entry, so uploading one through the shared partition, or
|
||||
deleting a file or folder that holds one, also needs `library.write`.
|
||||
- The emulated USB devices are neither media nor network: the virtual disk and the RNDIS adapter
|
||||
hand the target machine the raw data partition and a network interface, so both follow `device`.
|
||||
A medium can only be attached while a `device` holder has the virtual disk switched on, and the
|
||||
storage page says so when it is off.
|
||||
|
||||
Four grants reach further than their names suggest and amount to full device control. The catalog
|
||||
has two risk levels, and `warn` is reserved for them: the account editor marks those four and
|
||||
names them before the grants are saved:
|
||||
|
||||
- `terminal` is a root shell (the same one SSH gives) and can run saved and autostart scripts.
|
||||
- `mcp` hands out a device-wide API key that drives the keyboard, mouse and screenshots from
|
||||
outside the web session. The key is shared, so a copy keeps working after an account loses this
|
||||
grant. Whenever a change takes the grant away (revoked, demoted, disabled or deleted), the account
|
||||
editor warns and offers to regenerate the key, checked by default; regenerating disconnects every
|
||||
MCP client until it is given the new key, and an administrator who keeps the key accepts that a
|
||||
copy stays valid. The API takes the choice as `rotateMcpKey` (a body field on update, a query
|
||||
parameter on delete) and never rotates without it.
|
||||
- `ai` drives the device through the local loopback interface, which the other grants do not
|
||||
restrict.
|
||||
- `update` installs firmware and application packages. An offline package is checked only against
|
||||
the checksum its uploader supplies, so installing one is running code as root.
|
||||
|
||||
`device` carries no such mark, although it changes what the device does for everyone and cannot be
|
||||
narrowed per account. `device` turns SSH on or off (root login, with the device
|
||||
owner's password), reboots the device, hands the raw data partition or a network interface to the
|
||||
target machine through the emulated USB devices, and edits the OLED, HDMI, mDNS, memory, swap and
|
||||
HID settings. The editor describes it that way.
|
||||
|
||||
Two grants also change settings that affect everyone watching: `console.view` sets the video mode,
|
||||
resolution, frame rate and quality, and resets the HDMI capture; `power` manages the Wake-on-LAN
|
||||
device list. Both are described that way in the editor.
|
||||
|
||||
Administrators manage accounts from **Settings > Account**. Account data remains in
|
||||
`/etc/kvm/pwd`; the server migrates the legacy single-account JSON format in place and writes
|
||||
@@ -77,12 +137,31 @@ Users must confirm their current password when changing it themselves; administr
|
||||
non-owner users from the account manager. Only the device owner can change the device owner's
|
||||
password, because that password is also synchronized to the Linux root account.
|
||||
|
||||
All authenticated sessions are backed by the current account state. Disabling, deleting,
|
||||
changing the role or password of a user invalidates that user's HTTP and real-time connections
|
||||
without affecting other users. Multiple users may watch and control the KVM concurrently; input
|
||||
uses the existing cooperative HID coordinator, so simultaneous input can interleave.
|
||||
Video mode, quality, resolution, and MJPEG frame-detection controls remain shared KVM
|
||||
operations; when several users adjust them concurrently, the latest change applies device-wide.
|
||||
All authenticated sessions are backed by the current account state: every request is authorized
|
||||
against the account file, so a revoked grant stops working immediately without re-issuing a login.
|
||||
Disabling, deleting, or resetting the password of a user invalidates that user's HTTP and real-time
|
||||
connections without affecting other users. Changing the role or the grants of an account keeps its
|
||||
login valid: the account is told to refresh, and only the long-lived connections whose cached
|
||||
state depends on what changed are dropped (console, video stream, terminal, AI), so those clients
|
||||
reconnect under the new grants. An account that loses `console.view` is sent to the landing page
|
||||
instead of a console it can no longer open. Multiple
|
||||
users may watch and control the KVM concurrently; input uses the existing cooperative HID
|
||||
coordinator, so simultaneous input can interleave. A user without `console.control` keeps the
|
||||
video and the menu for its other grants, but the console rejects keyboard and mouse reports and
|
||||
the web UI marks the session as view only.
|
||||
Video mode, quality, resolution, MJPEG frame-detection controls, and resetting the HDMI capture
|
||||
require only `console.view` and stay shared KVM operations; when several users adjust them
|
||||
concurrently, the latest change applies device-wide. Enabling or disabling the HDMI capture and
|
||||
its idle timeout are device settings. `console.control` is input only: keyboard, mouse, paste, and
|
||||
the input region that maps pointer motion onto the target screen.
|
||||
|
||||
The account file stores an explicit grant set: a record written before fine-grained permissions
|
||||
keeps exactly what the user role used to grant, and the split media grants are translated on load,
|
||||
so the store never holds a missing set that would fall back to different behavior later. Grants
|
||||
this build does not know are preserved and ignored, so a downgrade cannot lock the device out.
|
||||
Creating a regular account without naming a grant set keeps that same meaning, so a script that only
|
||||
knows about roles gets the account the user role used to create; the account editor seeds a new
|
||||
regular account with `console.view` only, and an explicitly empty set really is empty.
|
||||
|
||||
## Compile & Deploy
|
||||
|
||||
|
||||
@@ -64,17 +64,75 @@ turn:
|
||||
|
||||
NanoKVM 使用两种设备级角色:
|
||||
|
||||
- `admin`:除 KVM 操作外,还可管理用户、系统、网络、更新、存储、终端、脚本、MCP 和 PicoClaw。
|
||||
- `user`:可使用 KVM 视频、键盘、鼠标、粘贴、目标机电源/复位和网络唤醒。
|
||||
- `admin`:拥有以下全部能力,并可管理用户。
|
||||
- `user`:仅拥有被授予的功能权限。
|
||||
|
||||
每项权限都描述“对谁做什么”,服务端下发给账户编辑器的权限目录还带有分组、风险等级和蕴含关系:
|
||||
|
||||
| 分组 | 权限 |
|
||||
| --- | --- |
|
||||
| 控制台(本会话) | `console.view`、`console.control` |
|
||||
| 被控机 | `power`、`medium.attach` |
|
||||
| 介质库(本设备) | `library.read`、`library.write` |
|
||||
| 本设备 | `terminal`、`device`、`network`、`update` |
|
||||
| 服务 | `mcp`、`tailscale`、`ai` |
|
||||
|
||||
蕴含关系只在权限目录里声明一次,并由服务端在应答任何请求前解析:`console.control` 蕴含
|
||||
`console.view`,`medium.attach` 与 `library.write` 都蕴含 `library.read`;而控制项位于控制台菜单里的
|
||||
权限——`power`、`device`、`network`、`update`、`mcp`、`tailscale`、`ai`——同样蕴含 `console.view`,
|
||||
因为控制台之外没有任何页面渲染它们。由此得到几个行为:
|
||||
|
||||
- 账户可以完全没有控制台权限。此时 Web 界面会落到一个提示页,列出该账户可用的页面
|
||||
(介质库、终端),而不是打开一个它无权访问的控制台。媒体功能只需要 `library.read`,
|
||||
因为另外两项媒体权限都蕴含它;也只有 `terminal` 与媒体权限能拥有控制台之外的独立页面。
|
||||
- 媒体权限按信任边界拆分:`medium.attach` 在被控机上挂载/弹出介质,并写入被控机可读的共享分区;
|
||||
`library.write` 修改库内容(上传、删除、镜像内的文件);`library.read` 负责列表与下载。
|
||||
文件相关接口用同一路径同时服务镜像与共享分区,因此路由同时接受这两项权限之一,
|
||||
再按请求指定的介质类型做校验。分区上的每个 `.iso`、`.img` 都属于介质库,因此经共享分区上传这类文件,
|
||||
或删除本身是/内含这类文件的文件或文件夹,同样需要 `library.write`。
|
||||
- 模拟的 USB 设备既不属于媒体也不属于网络:虚拟磁盘与 RNDIS 虚拟网卡分别把设备的原始数据分区
|
||||
和一块网卡交给被控机,因此都归 `device`。只有拥有 `device` 权限的账户开启虚拟磁盘后,才能挂载
|
||||
镜像;磁盘未开启时存储页面会给出提示。
|
||||
|
||||
以下四项权限的实际范围比名称更大,等同于完整的设备控制权;权限目录只有两个风险等级,`warn`
|
||||
专用于它们,账户编辑器会做标记并在保存前按名称列出:
|
||||
|
||||
- `terminal`:即 root shell(与 SSH 相同),并可运行已保存的脚本和开机自启脚本。
|
||||
- `mcp`:会下发设备级 API Key,可在 Web 会话之外控制键鼠并获取截图。Key 为共用,账户失去该权限后已复制的
|
||||
Key 仍然有效。任何会让账户失去该权限的操作(撤销、降级、禁用或删除),账户编辑器都会警告并提供「重新生成
|
||||
Key」选项(默认勾选);重新生成会断开所有 MCP 客户端直到换用新 Key,管理员选择保留则自行承担副本仍有效的风险。
|
||||
API 通过 `rotateMcpKey` 传递该选择(更新时放在请求体,删除时放在查询参数),不带它就不会轮换。
|
||||
- `ai`:通过本地回环接口操作设备,不受其他权限限制。
|
||||
- `update`:安装固件与应用更新包。离线包只校验上传者自己提供的校验和,安装它即等于以 root 运行代码。
|
||||
|
||||
`device` 不做该标记:它仍在本账户被授予的设置范围内,但会改变设备对所有人生效的行为,
|
||||
且无法按账户收窄。`device` 可开关 SSH(root,口令为设备所有者口令)、重启设备、通过模拟的 USB 设备把
|
||||
原始数据分区或网卡交给被控机,并修改 OLED、HDMI、mDNS、内存、交换与 HID 设置。编辑器中对此同样有说明。
|
||||
|
||||
另有两项权限会改动影响所有观看者的设置:`console.view` 会设置视频模式、分辨率、帧率与画质,
|
||||
并复位 HDMI 采集;`power` 会管理网络唤醒设备列表。编辑器中对这两项都有明确说明。
|
||||
|
||||
管理员可在**设置 > 账户**中管理用户。账户数据继续保存在 `/etc/kvm/pwd`;服务会原地迁移旧版单账户
|
||||
JSON,并以 `0600` 权限原子写入多用户格式。沿用同一路径可保持长按 BOOT 键重置密码的现有行为。
|
||||
用户自助修改密码时必须验证当前密码;管理员可在账户管理中重置非设备所有者的密码。由于设备所有者
|
||||
密码还会同步至 Linux root 账户,因此只有设备所有者本人可以修改自己的密码。
|
||||
|
||||
所有登录会话都会校验当前账户状态。禁用、删除、修改角色或密码会立即撤销该用户的 HTTP 与实时连接,
|
||||
且不会影响其他用户。多个用户可同时观看和协作控制 KVM;输入沿用现有 HID 协调器,因此同时输入可能交错。
|
||||
视频模式、画质、分辨率和 MJPEG 帧检测仍属于共享 KVM 操作;多人同时调整时,以最后一次设备级修改为准。
|
||||
所有登录会话都会校验当前账户状态:每个请求都按账户文件重新授权,因此被撤销的权限会立即失效,
|
||||
无需重新登录。禁用、删除或重置密码会立即撤销该用户的 HTTP 与实时连接,且不会影响其他用户。
|
||||
修改角色或权限不会失效登录:账户会收到刷新通知,只会断开缓存了相关状态的长期连接
|
||||
(控制台、视频流、终端、AI),这些客户端随后会以新权限重连。失去 `console.view` 的账户会被
|
||||
转到提示页,而不是一个无法打开的控制台。多个用户可同时观看和协作控制 KVM;输入沿用现有 HID
|
||||
协调器,因此同时输入可能交错。没有 `console.control` 的用户仍可观看画面并使用其其他权限对应的菜单,
|
||||
但控制台会丢弃其键鼠报文,Web 界面会将会话标记为只读。
|
||||
视频模式、画质、分辨率、MJPEG 帧检测以及复位 HDMI 采集只要求 `console.view`,仍属于共享 KVM 操作;
|
||||
多人同时调整时,以最后一次设备级修改为准。启用/关闭 HDMI 采集及其空闲超时则属于设备设置。
|
||||
`console.control` 仅限输入:键盘、鼠标、粘贴,以及将指针位移映射到目标机画面的输入区域。
|
||||
|
||||
账户文件保存的是显式权限集合:细粒度权限出现之前写入的记录,在读取时会物化为当时 user 角色的
|
||||
能力,拆分过的媒体权限也会被翻译成新名称,因此存储中不会留下“缺失即回退到另一套行为”的状态。
|
||||
本版本不认识的权限会被原样保留并忽略,因此回滚版本不会把设备锁在门外。
|
||||
新建普通账户时若不指定权限集合,含义同上:只认识角色的脚本仍会得到 user 角色过去创建出的账户;
|
||||
账户编辑器新建普通账户时只预置 `console.view`,而显式传入的空集合就是真的空。
|
||||
|
||||
## 编译部署
|
||||
|
||||
|
||||
322
server/authn/permissions.go
Normal file
322
server/authn/permissions.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Permission identifies a grantable capability. The admin role implicitly holds
|
||||
// every permission; the user role only holds the permissions stored on the
|
||||
// account.
|
||||
//
|
||||
// A permission names what it lets the account do, not the settings page it
|
||||
// lives on, and every grant that changes something shared says so in the
|
||||
// catalog's risk and effect metadata instead of in the label alone.
|
||||
type Permission string
|
||||
|
||||
const (
|
||||
// Console grants.
|
||||
PermConsoleView Permission = "console.view"
|
||||
PermConsoleControl Permission = "console.control"
|
||||
|
||||
// Target-machine grants.
|
||||
PermPower Permission = "power"
|
||||
PermMediumAttach Permission = "medium.attach"
|
||||
|
||||
// Media library grants.
|
||||
PermLibraryRead Permission = "library.read"
|
||||
PermLibraryWrite Permission = "library.write"
|
||||
|
||||
// Host grants.
|
||||
PermTerminal Permission = "terminal"
|
||||
PermDevice Permission = "device"
|
||||
PermNetwork Permission = "network"
|
||||
PermUpdate Permission = "update"
|
||||
|
||||
// Service grants.
|
||||
PermMcp Permission = "mcp"
|
||||
PermTailscale Permission = "tailscale"
|
||||
PermAi Permission = "ai"
|
||||
)
|
||||
|
||||
// Group names the party a grant reaches. The account editor renders one section
|
||||
// per group, so the sections stay meaningful as the catalog grows.
|
||||
type Group string
|
||||
|
||||
const (
|
||||
// GroupConsole is what an account may do with the console it is watching.
|
||||
GroupConsole Group = "console"
|
||||
// GroupTarget is what it may do to the machine behind the console.
|
||||
GroupTarget Group = "target"
|
||||
// GroupLibrary is what it may do with the media library on this device.
|
||||
GroupLibrary Group = "library"
|
||||
// GroupHost is what it may change on this device.
|
||||
GroupHost Group = "host"
|
||||
// GroupServices are the add-ons that reach the device on their own.
|
||||
GroupServices Group = "services"
|
||||
)
|
||||
|
||||
// Risk states how far a grant reaches, and the catalog carries two levels: a
|
||||
// plain grant, and the grants that amount to full device control, which the
|
||||
// account editor marks and names before the grants are saved.
|
||||
type Risk string
|
||||
|
||||
const (
|
||||
// RiskInfo is a grant that stays within the feature it names.
|
||||
RiskInfo Risk = "info"
|
||||
// RiskWarn is a grant that amounts to full device control: a root shell,
|
||||
// the AI loopback, an API key that drives input outside the web session, and
|
||||
// a package install whose checksum the uploader supplies.
|
||||
RiskWarn Risk = "warn"
|
||||
)
|
||||
|
||||
// Descriptor is one catalog entry: the grant, the group it belongs to, the risk
|
||||
// an administrator takes by granting it, and the grants it implies.
|
||||
type Descriptor struct {
|
||||
Permission Permission `json:"id"`
|
||||
Group Group `json:"group"`
|
||||
Risk Risk `json:"risk"`
|
||||
Implies []Permission `json:"implies,omitempty"`
|
||||
}
|
||||
|
||||
// Implied grants are declared once here instead of as unions at every call
|
||||
// site: control without the picture is meaningless, nothing can be attached or
|
||||
// changed in the library without being allowed to see it first, and the grants
|
||||
// whose controls live in the console menu imply the console, because no page
|
||||
// outside it renders them and a grant nothing can use is a promise the app
|
||||
// cannot keep. Terminal and the media grants stand on their own pages.
|
||||
var permissionCatalog = []Descriptor{
|
||||
{Permission: PermConsoleView, Group: GroupConsole, Risk: RiskInfo},
|
||||
{
|
||||
Permission: PermConsoleControl,
|
||||
Group: GroupConsole,
|
||||
Risk: RiskInfo,
|
||||
Implies: []Permission{PermConsoleView},
|
||||
},
|
||||
{
|
||||
Permission: PermPower,
|
||||
Group: GroupTarget,
|
||||
Risk: RiskInfo,
|
||||
Implies: []Permission{PermConsoleView},
|
||||
},
|
||||
{
|
||||
Permission: PermMediumAttach,
|
||||
Group: GroupTarget,
|
||||
Risk: RiskInfo,
|
||||
Implies: []Permission{PermLibraryRead},
|
||||
},
|
||||
{Permission: PermLibraryRead, Group: GroupLibrary, Risk: RiskInfo},
|
||||
{
|
||||
Permission: PermLibraryWrite,
|
||||
Group: GroupLibrary,
|
||||
Risk: RiskInfo,
|
||||
Implies: []Permission{PermLibraryRead},
|
||||
},
|
||||
{Permission: PermTerminal, Group: GroupHost, Risk: RiskWarn},
|
||||
{
|
||||
Permission: PermDevice,
|
||||
Group: GroupHost,
|
||||
Risk: RiskInfo,
|
||||
Implies: []Permission{PermConsoleView},
|
||||
},
|
||||
{
|
||||
Permission: PermNetwork,
|
||||
Group: GroupHost,
|
||||
Risk: RiskInfo,
|
||||
Implies: []Permission{PermConsoleView},
|
||||
},
|
||||
{
|
||||
Permission: PermUpdate,
|
||||
Group: GroupHost,
|
||||
Risk: RiskWarn,
|
||||
Implies: []Permission{PermConsoleView},
|
||||
},
|
||||
{
|
||||
Permission: PermMcp,
|
||||
Group: GroupServices,
|
||||
Risk: RiskWarn,
|
||||
Implies: []Permission{PermConsoleView},
|
||||
},
|
||||
{
|
||||
Permission: PermTailscale,
|
||||
Group: GroupServices,
|
||||
Risk: RiskInfo,
|
||||
Implies: []Permission{PermConsoleView},
|
||||
},
|
||||
{
|
||||
Permission: PermAi,
|
||||
Group: GroupServices,
|
||||
Risk: RiskWarn,
|
||||
Implies: []Permission{PermConsoleView},
|
||||
},
|
||||
}
|
||||
|
||||
// Grants of earlier releases, kept so an account file written by one of them
|
||||
// keeps meaning what it meant. media.mount and media.manage split into the
|
||||
// attach/read/write grants above.
|
||||
const (
|
||||
legacyPermMediaMount Permission = "media.mount"
|
||||
legacyPermMediaManage Permission = "media.manage"
|
||||
)
|
||||
|
||||
// Catalog returns the catalog in display order.
|
||||
func Catalog() []Descriptor {
|
||||
catalog := make([]Descriptor, len(permissionCatalog))
|
||||
for index, descriptor := range permissionCatalog {
|
||||
catalog[index] = descriptor
|
||||
catalog[index].Implies = append([]Permission(nil), descriptor.Implies...)
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
|
||||
// PermissionIDs returns every grantable permission in catalog order.
|
||||
func PermissionIDs() []Permission {
|
||||
ids := make([]Permission, 0, len(permissionCatalog))
|
||||
for _, descriptor := range permissionCatalog {
|
||||
ids = append(ids, descriptor.Permission)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func IsValidPermission(permission Permission) bool {
|
||||
for _, descriptor := range permissionCatalog {
|
||||
if descriptor.Permission == permission {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidatePermissions rejects unknown or duplicate permissions. It is for grant
|
||||
// sets arriving in a request; the grant set stored on an account is validated by
|
||||
// ValidateStoredPermissions, which tolerates ids this build does not know.
|
||||
func ValidatePermissions(permissions []Permission) error {
|
||||
seen := make(map[Permission]struct{}, len(permissions))
|
||||
for _, permission := range permissions {
|
||||
if !IsValidPermission(permission) {
|
||||
return fmt.Errorf("unknown permission: %q", permission)
|
||||
}
|
||||
if _, exists := seen[permission]; exists {
|
||||
return fmt.Errorf("duplicate permission: %q", permission)
|
||||
}
|
||||
seen[permission] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateStoredPermissions only rejects duplicates. An account file written by
|
||||
// a newer release carries grants this build cannot evaluate, and refusing to
|
||||
// load it would lock the device out instead of ignoring a capability that is
|
||||
// unavailable here anyway.
|
||||
func ValidateStoredPermissions(permissions []Permission) error {
|
||||
seen := make(map[Permission]struct{}, len(permissions))
|
||||
for _, permission := range permissions {
|
||||
if _, exists := seen[permission]; exists {
|
||||
return fmt.Errorf("duplicate permission: %q", permission)
|
||||
}
|
||||
seen[permission] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Has reports whether the set contains the permission. Administrators are not
|
||||
// special-cased: callers that should honor the role's implicit grants resolve
|
||||
// EffectivePermissions first.
|
||||
func Has(permissions []Permission, permission Permission) bool {
|
||||
for _, granted := range permissions {
|
||||
if granted == permission {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Closure resolves the grants an account ends up holding: the set itself plus
|
||||
// everything it implies, transitively, in catalog order.
|
||||
func Closure(permissions []Permission) []Permission {
|
||||
granted := closureSet(permissions)
|
||||
|
||||
resolved := make([]Permission, 0, len(granted))
|
||||
for _, descriptor := range permissionCatalog {
|
||||
if _, ok := granted[descriptor.Permission]; ok {
|
||||
resolved = append(resolved, descriptor.Permission)
|
||||
delete(granted, descriptor.Permission)
|
||||
}
|
||||
}
|
||||
// Keep ids this build does not know in the order they were stored, so they
|
||||
// still round-trip through the client.
|
||||
for _, permission := range permissions {
|
||||
if _, ok := granted[permission]; ok {
|
||||
resolved = append(resolved, permission)
|
||||
delete(granted, permission)
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func closureSet(permissions []Permission) map[Permission]struct{} {
|
||||
granted := make(map[Permission]struct{}, len(permissions))
|
||||
var add func(Permission)
|
||||
add = func(permission Permission) {
|
||||
if _, exists := granted[permission]; exists {
|
||||
return
|
||||
}
|
||||
granted[permission] = struct{}{}
|
||||
for _, descriptor := range permissionCatalog {
|
||||
if descriptor.Permission != permission {
|
||||
continue
|
||||
}
|
||||
for _, implied := range descriptor.Implies {
|
||||
add(implied)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, permission := range permissions {
|
||||
add(permission)
|
||||
}
|
||||
return granted
|
||||
}
|
||||
|
||||
// NormalizeStoredPermissions translates the grants an earlier release wrote and
|
||||
// removes duplicates, so the store holds one spelling of every capability.
|
||||
// Unknown ids are preserved: a downgrade must not silently drop a grant.
|
||||
func NormalizeStoredPermissions(permissions []Permission) []Permission {
|
||||
normalized := make([]Permission, 0, len(permissions)+2)
|
||||
appendOnce := func(permission Permission) {
|
||||
if !Has(normalized, permission) {
|
||||
normalized = append(normalized, permission)
|
||||
}
|
||||
}
|
||||
for _, permission := range permissions {
|
||||
switch permission {
|
||||
case legacyPermMediaMount:
|
||||
// Attaching a medium is what let a watcher read the library.
|
||||
appendOnce(PermMediumAttach)
|
||||
appendOnce(PermLibraryRead)
|
||||
case legacyPermMediaManage:
|
||||
appendOnce(PermLibraryRead)
|
||||
appendOnce(PermLibraryWrite)
|
||||
default:
|
||||
appendOnce(permission)
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// legacyUserPermissions preserves the capabilities the user role had before
|
||||
// fine-grained permissions existed: full console access and power control.
|
||||
func legacyUserPermissions() []Permission {
|
||||
return []Permission{PermConsoleView, PermConsoleControl, PermPower}
|
||||
}
|
||||
|
||||
// EffectivePermissions resolves what a user can actually do: administrators
|
||||
// hold the whole catalog, regular users hold their stored grants and whatever
|
||||
// those imply.
|
||||
func EffectivePermissions(user *User) []Permission {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
if user.Role == RoleAdmin {
|
||||
return PermissionIDs()
|
||||
}
|
||||
return Closure(user.Permissions)
|
||||
}
|
||||
86
server/authn/permissions_test.go
Normal file
86
server/authn/permissions_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClosureResolvesImpliedGrants(t *testing.T) {
|
||||
cases := []struct {
|
||||
grants []Permission
|
||||
want []Permission
|
||||
}{
|
||||
{[]Permission{PermConsoleControl}, []Permission{PermConsoleView, PermConsoleControl}},
|
||||
{[]Permission{PermMediumAttach}, []Permission{PermMediumAttach, PermLibraryRead}},
|
||||
{[]Permission{PermLibraryWrite}, []Permission{PermLibraryRead, PermLibraryWrite}},
|
||||
{[]Permission{PermTerminal}, []Permission{PermTerminal}},
|
||||
{[]Permission{"future.grant", PermMcp}, []Permission{PermConsoleView, PermMcp, "future.grant"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := Closure(tc.grants); !slices.Equal(got, tc.want) {
|
||||
t.Errorf("Closure(%v) = %v, want %v", tc.grants, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTranslatesLegacyMediaGrants(t *testing.T) {
|
||||
got := NormalizeStoredPermissions([]Permission{legacyPermMediaMount, legacyPermMediaManage, PermPower})
|
||||
want := []Permission{PermMediumAttach, PermLibraryRead, PermLibraryWrite, PermPower}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("NormalizeStoredPermissions = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateReturnsPreviousAndKeepsUnknownGrants(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pwd")
|
||||
store := NewStore(path)
|
||||
if err := store.Create("alice", "password", RoleUser, PermConsoleView); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A newer release stored a grant this build does not know.
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var db database
|
||||
if err = json.Unmarshal(data, &db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
index := userIndex(db.Users, "alice")
|
||||
db.Users[index].Permissions = append(db.Users[index].Permissions, "future.grant")
|
||||
if data, err = json.Marshal(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
grants := []Permission{PermConsoleControl}
|
||||
previous, updated, err := store.Update("admin", "alice", UserPatch{Permissions: &grants})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(previous.Permissions, []Permission{PermConsoleView, "future.grant"}) {
|
||||
t.Fatalf("previous permissions = %v", previous.Permissions)
|
||||
}
|
||||
if !slices.Equal(updated.Permissions, []Permission{PermConsoleControl, "future.grant"}) {
|
||||
t.Fatalf("updated permissions = %v", updated.Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyFullControlGrantsWarn(t *testing.T) {
|
||||
var warned []Permission
|
||||
for _, descriptor := range Catalog() {
|
||||
if descriptor.Risk == RiskWarn {
|
||||
warned = append(warned, descriptor.Permission)
|
||||
}
|
||||
}
|
||||
want := []Permission{PermTerminal, PermUpdate, PermMcp, PermAi}
|
||||
if !slices.Equal(warned, want) {
|
||||
t.Fatalf("warned grants = %v, want %v", warned, want)
|
||||
}
|
||||
}
|
||||
@@ -42,25 +42,28 @@ var (
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"password"`
|
||||
Role Role `json:"role"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TokenVersion uint64 `json:"tokenVersion"`
|
||||
MustChangePassword bool `json:"mustChangePassword,omitempty"`
|
||||
SystemAccount bool `json:"systemAccount,omitempty"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"password"`
|
||||
Role Role `json:"role"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TokenVersion uint64 `json:"tokenVersion"`
|
||||
Permissions []Permission `json:"permissions"`
|
||||
MustChangePassword bool `json:"mustChangePassword,omitempty"`
|
||||
SystemAccount bool `json:"systemAccount,omitempty"`
|
||||
}
|
||||
|
||||
type UserInfo struct {
|
||||
Username string `json:"username"`
|
||||
Role Role `json:"role"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SystemAccount bool `json:"systemAccount,omitempty"`
|
||||
Username string `json:"username"`
|
||||
Role Role `json:"role"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Permissions []Permission `json:"permissions"`
|
||||
SystemAccount bool `json:"systemAccount,omitempty"`
|
||||
}
|
||||
|
||||
type UserPatch struct {
|
||||
Role *Role
|
||||
Enabled *bool
|
||||
Role *Role
|
||||
Enabled *bool
|
||||
Permissions *[]Permission
|
||||
}
|
||||
|
||||
type database struct {
|
||||
@@ -115,11 +118,16 @@ func (s *Store) List() ([]UserInfo, error) {
|
||||
}
|
||||
|
||||
users := make([]UserInfo, 0, len(db.Users))
|
||||
for _, user := range db.Users {
|
||||
for index := range db.Users {
|
||||
user := &db.Users[index]
|
||||
// The stored grants, not the effective ones: the editor shows what an
|
||||
// administrator set, and what an account implies is derived from the
|
||||
// catalog instead of being written back on every save.
|
||||
users = append(users, UserInfo{
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
Enabled: user.Enabled,
|
||||
Permissions: clonePermissions(user.Permissions),
|
||||
SystemAccount: user.SystemAccount,
|
||||
})
|
||||
}
|
||||
@@ -192,7 +200,7 @@ func (s *Store) ValidateToken(username string, tokenVersion uint64) (*User, erro
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Store) Create(username, password string, role Role) error {
|
||||
func (s *Store) Create(username, password string, role Role, permissions ...Permission) error {
|
||||
if err := ValidateUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -202,6 +210,16 @@ func (s *Store) Create(username, password string, role Role) error {
|
||||
if !IsValidRole(role) {
|
||||
return errors.New("invalid role")
|
||||
}
|
||||
// A nil set means the caller named none at all, which keeps the meaning it
|
||||
// had before grants existed: an account created by a script that only knows
|
||||
// about roles gets what the user role granted then. An explicit empty set is
|
||||
// a decision and stays empty, so the account really holds nothing.
|
||||
if permissions == nil && role == RoleUser {
|
||||
permissions = legacyUserPermissions()
|
||||
}
|
||||
if err := ValidatePermissions(permissions); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@@ -227,37 +245,66 @@ func (s *Store) Create(username, password string, role Role) error {
|
||||
Role: role,
|
||||
Enabled: true,
|
||||
TokenVersion: tokenVersion,
|
||||
Permissions: NormalizeStoredPermissions(permissions),
|
||||
})
|
||||
return s.saveLocked(db)
|
||||
}
|
||||
|
||||
func (s *Store) Update(actor, username string, patch UserPatch) (*User, error) {
|
||||
// Update applies the patch and returns the account as it was before and after,
|
||||
// both read under the store lock, so a caller comparing the two sees exactly the
|
||||
// change it made even while another update runs.
|
||||
func (s *Store) Update(actor, username string, patch UserPatch) (previous, updated *User, err error) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
db, err := s.loadLocked(true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
index := userIndex(db.Users, username)
|
||||
if index < 0 {
|
||||
return nil, ErrUserNotFound
|
||||
return nil, nil, ErrUserNotFound
|
||||
}
|
||||
previous = cloneUser(&db.Users[index])
|
||||
user := db.Users[index]
|
||||
|
||||
if patch.Role != nil && !IsValidRole(*patch.Role) {
|
||||
return nil, errors.New("invalid role")
|
||||
return nil, nil, errors.New("invalid role")
|
||||
}
|
||||
// The patch is normalized before it is compared and stored, so a caller that
|
||||
// still sends the split media grants does not look like a change once they
|
||||
// resolve to the grants they now mean.
|
||||
granted := user.Permissions
|
||||
if patch.Permissions != nil {
|
||||
if err := ValidatePermissions(*patch.Permissions); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
granted = NormalizeStoredPermissions(*patch.Permissions)
|
||||
// A request can only name grants this build knows, so the ones it does
|
||||
// not (written by a newer release) are carried over instead of being
|
||||
// dropped by an editor that never showed them.
|
||||
for _, permission := range user.Permissions {
|
||||
if !IsValidPermission(permission) && !Has(granted, permission) {
|
||||
granted = append(granted, permission)
|
||||
}
|
||||
}
|
||||
}
|
||||
if actor == username && user.Role == RoleAdmin &&
|
||||
((patch.Role != nil && *patch.Role != RoleAdmin) || (patch.Enabled != nil && !*patch.Enabled)) {
|
||||
return nil, ErrSelfModification
|
||||
return nil, nil, ErrSelfModification
|
||||
}
|
||||
if user.SystemAccount &&
|
||||
((patch.Role != nil && *patch.Role != RoleAdmin) || (patch.Enabled != nil && !*patch.Enabled)) {
|
||||
return nil, ErrSystemAccount
|
||||
return nil, nil, ErrSystemAccount
|
||||
}
|
||||
|
||||
changed := false
|
||||
// Only disabling the account invalidates its login. A role change does not:
|
||||
// the principal is rebuilt from the account file on every request, and the
|
||||
// caller refreshes the user's long-lived connections so they reconnect under
|
||||
// the role's new grants. Bumping the token version for a role change would
|
||||
// log the account out mid-session just because it was promoted or demoted.
|
||||
loginInvalidated := false
|
||||
if patch.Role != nil && user.Role != *patch.Role {
|
||||
user.Role = *patch.Role
|
||||
changed = true
|
||||
@@ -265,20 +312,30 @@ func (s *Store) Update(actor, username string, patch UserPatch) (*User, error) {
|
||||
if patch.Enabled != nil && user.Enabled != *patch.Enabled {
|
||||
user.Enabled = *patch.Enabled
|
||||
changed = true
|
||||
loginInvalidated = true
|
||||
}
|
||||
if patch.Permissions != nil && !permissionsEqual(user.Permissions, granted) {
|
||||
user.Permissions = granted
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return &user, nil
|
||||
return previous, cloneUser(&user), nil
|
||||
}
|
||||
|
||||
db.Users[index] = user
|
||||
if enabledAdminCount(db.Users) == 0 {
|
||||
return nil, ErrLastAdmin
|
||||
return nil, nil, ErrLastAdmin
|
||||
}
|
||||
// Permission and role updates keep the login valid: the principal is rebuilt
|
||||
// from the store on every request, so new grants apply immediately and
|
||||
// revoked ones are enforced by dropping connections from the handler.
|
||||
if loginInvalidated {
|
||||
db.Users[index].TokenVersion++
|
||||
}
|
||||
db.Users[index].TokenVersion++
|
||||
if err = s.saveLocked(db); err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
return cloneUser(&db.Users[index]), nil
|
||||
return previous, cloneUser(&db.Users[index]), nil
|
||||
}
|
||||
|
||||
func (s *Store) Delete(actor, username string) error {
|
||||
@@ -378,6 +435,7 @@ func (s *Store) loadLocked(migrate bool) (*database, error) {
|
||||
if defaultErr != nil {
|
||||
return nil, defaultErr
|
||||
}
|
||||
materializePermissions(db)
|
||||
if migrate {
|
||||
if saveErr := s.saveLocked(db); saveErr != nil {
|
||||
return nil, saveErr
|
||||
@@ -391,6 +449,7 @@ func (s *Store) loadLocked(migrate bool) (*database, error) {
|
||||
|
||||
var db database
|
||||
if err = json.Unmarshal(data, &db); err == nil && db.Version != 0 {
|
||||
materializePermissions(&db)
|
||||
if err = validateDatabase(&db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -420,6 +479,7 @@ func (s *Store) loadLocked(migrate bool) (*database, error) {
|
||||
SystemAccount: true,
|
||||
}},
|
||||
}
|
||||
materializePermissions(&db)
|
||||
if migrate {
|
||||
if err = s.saveLocked(&db); err != nil {
|
||||
return nil, err
|
||||
@@ -528,6 +588,9 @@ func validateDatabase(db *database) error {
|
||||
if user.PasswordHash == "" || !IsValidRole(user.Role) {
|
||||
return errors.New("account file contains an invalid user")
|
||||
}
|
||||
if err := ValidateStoredPermissions(user.Permissions); err != nil {
|
||||
return fmt.Errorf("account file contains invalid permissions for %q: %w", user.Username, err)
|
||||
}
|
||||
if _, exists := seen[user.Username]; exists {
|
||||
return errors.New("account file contains duplicate usernames")
|
||||
}
|
||||
@@ -565,6 +628,46 @@ func userIndex(users []User, username string) int {
|
||||
return -1
|
||||
}
|
||||
|
||||
// materializePermissions replaces the grant shapes of earlier releases with an
|
||||
// explicit set: an account that predates fine-grained permissions keeps exactly
|
||||
// what the user role used to grant, and the split media grants are translated.
|
||||
// After this the store never holds a nil set, so "no grants" and "never
|
||||
// configured" can no longer be confused, and no caller has to special-case nil.
|
||||
func materializePermissions(db *database) {
|
||||
for index := range db.Users {
|
||||
if db.Users[index].Permissions == nil {
|
||||
db.Users[index].Permissions = legacyUserPermissions()
|
||||
continue
|
||||
}
|
||||
db.Users[index].Permissions = NormalizeStoredPermissions(db.Users[index].Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
func clonePermissions(permissions []Permission) []Permission {
|
||||
return append([]Permission{}, permissions...)
|
||||
}
|
||||
|
||||
func permissionsEqual(a, b []Permission) bool {
|
||||
if (a == nil) != (b == nil) {
|
||||
return false
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
// The stored order carries no meaning, so a reordering must not look like
|
||||
// a change worth writing back.
|
||||
seen := make(map[Permission]struct{}, len(a))
|
||||
for _, permission := range a {
|
||||
seen[permission] = struct{}{}
|
||||
}
|
||||
for _, permission := range b {
|
||||
if _, exists := seen[permission]; !exists {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func enabledAdminCount(users []User) int {
|
||||
count := 0
|
||||
for _, user := range users {
|
||||
|
||||
@@ -96,14 +96,14 @@ func TestUserLifecycleAndTokenRevocation(t *testing.T) {
|
||||
}
|
||||
|
||||
disabled := false
|
||||
if _, err = store.Update("admin", "alice", UserPatch{Enabled: &disabled}); err != nil {
|
||||
if _, _, err = store.Update("admin", "alice", UserPatch{Enabled: &disabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, err := store.Authenticate("alice", "new-password"); err != nil || ok {
|
||||
t.Fatalf("disabled login: ok=%v err=%v", ok, err)
|
||||
}
|
||||
enabled := true
|
||||
if _, err = store.Update("admin", "alice", UserPatch{Enabled: &enabled}); err != nil {
|
||||
if _, _, err = store.Update("admin", "alice", UserPatch{Enabled: &enabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.Delete("admin", "alice"); err != nil {
|
||||
@@ -120,7 +120,7 @@ func TestLastAdminAndSelfProtection(t *testing.T) {
|
||||
t.Fatalf("default login: ok=%v err=%v", ok, err)
|
||||
}
|
||||
userRole := RoleUser
|
||||
if _, err := store.Update("admin", "admin", UserPatch{Role: &userRole}); !errors.Is(err, ErrSelfModification) {
|
||||
if _, _, err := store.Update("admin", "admin", UserPatch{Role: &userRole}); !errors.Is(err, ErrSelfModification) {
|
||||
t.Fatalf("self demotion error = %v", err)
|
||||
}
|
||||
if err := store.Delete("admin", "admin"); !errors.Is(err, ErrSelfDelete) {
|
||||
@@ -130,10 +130,10 @@ func TestLastAdminAndSelfProtection(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled := false
|
||||
if _, err := store.Update("second", "admin", UserPatch{Enabled: &disabled}); !errors.Is(err, ErrSystemAccount) {
|
||||
if _, _, err := store.Update("second", "admin", UserPatch{Enabled: &disabled}); !errors.Is(err, ErrSystemAccount) {
|
||||
t.Fatalf("disable device owner error = %v", err)
|
||||
}
|
||||
if _, err := store.Update("second", "admin", UserPatch{Role: &userRole}); !errors.Is(err, ErrSystemAccount) {
|
||||
if _, _, err := store.Update("second", "admin", UserPatch{Role: &userRole}); !errors.Is(err, ErrSystemAccount) {
|
||||
t.Fatalf("demote device owner error = %v", err)
|
||||
}
|
||||
if err := store.Delete("second", "admin"); !errors.Is(err, ErrSystemAccount) {
|
||||
|
||||
@@ -35,64 +35,49 @@ func GetKvmVision() *KvmVision {
|
||||
return kvmVision
|
||||
}
|
||||
|
||||
func (k *KvmVision) ReadMjpeg(width uint16, height uint16, quality uint16) (data []byte, result int) {
|
||||
k.mutex.RLock()
|
||||
defer k.mutex.RUnlock()
|
||||
if k.closed {
|
||||
return nil, -1
|
||||
}
|
||||
|
||||
var (
|
||||
kvmData *C.uint8_t
|
||||
dataSize C.uint32_t
|
||||
)
|
||||
|
||||
result = int(C.kvmv_read_img(
|
||||
C.uint16_t(width),
|
||||
C.uint16_t(height),
|
||||
C.uint8_t(0),
|
||||
C.uint16_t(quality),
|
||||
&kvmData,
|
||||
&dataSize,
|
||||
))
|
||||
if result < 0 {
|
||||
log.Errorf("failed to read kvm image: %v", result)
|
||||
return
|
||||
}
|
||||
defer C.free_kvmv_data(&kvmData)
|
||||
|
||||
data = C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
|
||||
return
|
||||
func (k *KvmVision) ReadMjpeg(width uint16, height uint16, quality uint16) ([]byte, int) {
|
||||
frame := k.AcquireMjpeg(width, height, quality, uint16(GetScreen().FPS))
|
||||
defer frame.Release()
|
||||
return frame.Data, frame.Result
|
||||
}
|
||||
|
||||
func (k *KvmVision) ReadH264(width uint16, height uint16, bitRate uint16) (data []byte, result int) {
|
||||
func (k *KvmVision) AcquireMjpeg(width, height, quality, fps uint16) *VideoFrame {
|
||||
return k.acquireFrame(width, height, quality, fps, 0)
|
||||
}
|
||||
|
||||
func (k *KvmVision) AcquireH264(width, height, bitRate, fps uint16) *VideoFrame {
|
||||
return k.acquireFrame(width, height, bitRate, fps, 1)
|
||||
}
|
||||
|
||||
func (k *KvmVision) acquireFrame(width, height, quality, fps uint16, codec uint8) *VideoFrame {
|
||||
k.mutex.RLock()
|
||||
defer k.mutex.RUnlock()
|
||||
if k.closed {
|
||||
return nil, -1
|
||||
return NewVideoFrame(nil, -1, 0, nil)
|
||||
}
|
||||
|
||||
var (
|
||||
kvmData *C.uint8_t
|
||||
dataSize C.uint32_t
|
||||
)
|
||||
|
||||
result = int(C.kvmv_read_img(
|
||||
C.uint16_t(width),
|
||||
C.uint16_t(height),
|
||||
C.uint8_t(1),
|
||||
C.uint16_t(bitRate),
|
||||
&kvmData,
|
||||
&dataSize,
|
||||
))
|
||||
if result < 0 {
|
||||
log.Errorf("failed to read kvm image: %v", result)
|
||||
return
|
||||
var frame C.kvmv_frame_t
|
||||
result := int(C.kvmv_read_img(C.uint16_t(width), C.uint16_t(height),
|
||||
C.uint8_t(codec), C.uint16_t(quality), C.uint16_t(fps), &frame))
|
||||
if frame.data == nil {
|
||||
return NewVideoFrame(nil, result, int64(frame.timestamp), nil)
|
||||
}
|
||||
defer C.free_kvmv_data(&kvmData)
|
||||
data := C.GoBytes(unsafe.Pointer(frame.data), C.int(frame.size))
|
||||
return NewVideoFrame(data, result, int64(frame.timestamp), func() {
|
||||
k.mutex.RLock()
|
||||
defer k.mutex.RUnlock()
|
||||
C.kvmv_release_frame(&frame)
|
||||
})
|
||||
}
|
||||
|
||||
data = C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
|
||||
return
|
||||
func (k *KvmVision) StopH264() { k.stopStream(1) }
|
||||
func (k *KvmVision) StopMjpeg() { k.stopStream(0) }
|
||||
|
||||
func (k *KvmVision) stopStream(codec uint8) {
|
||||
k.mutex.RLock()
|
||||
defer k.mutex.RUnlock()
|
||||
if !k.closed {
|
||||
C.kvmv_stop_stream(C.uint8_t(codec))
|
||||
}
|
||||
}
|
||||
|
||||
func (k *KvmVision) SetHDMI(enable bool) int {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package common
|
||||
|
||||
import "sync"
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Screen struct {
|
||||
Width uint16
|
||||
Height uint16
|
||||
FPS int
|
||||
Quality uint16
|
||||
BitRate uint16
|
||||
@@ -16,15 +19,6 @@ var (
|
||||
screenOnce sync.Once
|
||||
)
|
||||
|
||||
// ResolutionMap height to width
|
||||
var ResolutionMap = map[uint16]uint16{
|
||||
1080: 1920,
|
||||
720: 1280,
|
||||
600: 800,
|
||||
480: 640,
|
||||
0: 0,
|
||||
}
|
||||
|
||||
var QualityMap = map[uint16]bool{
|
||||
100: true,
|
||||
80: true,
|
||||
@@ -42,8 +36,6 @@ var BitRateMap = map[uint16]bool{
|
||||
func GetScreen() *Screen {
|
||||
screenOnce.Do(func() {
|
||||
screen = &Screen{
|
||||
Width: 0,
|
||||
Height: 0,
|
||||
Quality: 80,
|
||||
FPS: 30,
|
||||
BitRate: 3000,
|
||||
@@ -56,13 +48,6 @@ func GetScreen() *Screen {
|
||||
|
||||
func SetScreen(key string, value int) {
|
||||
switch key {
|
||||
case "resolution":
|
||||
height := uint16(value)
|
||||
if width, ok := ResolutionMap[height]; ok {
|
||||
screen.Width = width
|
||||
screen.Height = height
|
||||
}
|
||||
|
||||
case "quality":
|
||||
if value > 100 {
|
||||
screen.BitRate = uint16(value)
|
||||
@@ -79,11 +64,6 @@ func SetScreen(key string, value int) {
|
||||
}
|
||||
|
||||
func CheckScreen() {
|
||||
if _, ok := ResolutionMap[screen.Height]; !ok {
|
||||
screen.Width = 1920
|
||||
screen.Height = 1080
|
||||
}
|
||||
|
||||
if _, ok := QualityMap[screen.Quality]; !ok {
|
||||
screen.Quality = 80
|
||||
}
|
||||
@@ -93,6 +73,21 @@ func CheckScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
func InputSize() (uint16, uint16) {
|
||||
read := func(path string) uint16 {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 16)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return uint16(value)
|
||||
}
|
||||
return read("/kvmapp/kvm/width"), read("/kvmapp/kvm/height")
|
||||
}
|
||||
|
||||
func validateFPS(fps int) int {
|
||||
if fps > 60 {
|
||||
return 60
|
||||
|
||||
26
server/common/video_frame.go
Normal file
26
server/common/video_frame.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package common
|
||||
|
||||
import "sync"
|
||||
|
||||
type VideoFrame struct {
|
||||
Data []byte
|
||||
Result int
|
||||
Timestamp int64
|
||||
release func()
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func NewVideoFrame(data []byte, result int, timestamp int64, release func()) *VideoFrame {
|
||||
return &VideoFrame{Data: data, Result: result, Timestamp: timestamp, release: release}
|
||||
}
|
||||
|
||||
func (f *VideoFrame) Release() {
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
f.once.Do(func() {
|
||||
if f.release != nil {
|
||||
f.release()
|
||||
}
|
||||
})
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,71 +1,43 @@
|
||||
|
||||
|
||||
#ifndef KVM_VISION_H_
|
||||
#define KVM_VISION_H_
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
#include <fcntl.h> /* low-level i/o */
|
||||
#include <unistd.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <pthread.h>
|
||||
#include <time.h>
|
||||
|
||||
#define IMG_BUFFER_FULL -3
|
||||
#define IMG_VENC_ERROR -2
|
||||
#define IMG_NOT_EXIST -1
|
||||
#define IMG_MJPEG_TYPE 0
|
||||
#define IMG_H264_TYPE_SPS 1
|
||||
#define IMG_H264_TYPE_PPS 2
|
||||
#define IMG_H264_TYPE_IF 3
|
||||
#define IMG_H264_TYPE_PF 4
|
||||
#define IMG_BUFFER_FULL -3
|
||||
#define IMG_VENC_ERROR -2
|
||||
#define IMG_NOT_EXIST -1
|
||||
#define IMG_MJPEG_TYPE 0
|
||||
#define IMG_H264_TYPE_IF 3
|
||||
#define IMG_H264_TYPE_PF 4
|
||||
|
||||
#define NORMAL_RES 0
|
||||
#define NEW_RES 1
|
||||
#define UNSUPPORT_RES 2
|
||||
#define UNKNOWN_RES 3
|
||||
#define ERROR_RES 4
|
||||
typedef struct {
|
||||
uint8_t *data;
|
||||
uint32_t size;
|
||||
uint64_t timestamp;
|
||||
uint64_t token;
|
||||
uint8_t codec;
|
||||
} kvmv_frame_t;
|
||||
|
||||
void kvmv_init(uint8_t _debug_info_en);
|
||||
void set_venc_auto_recyc(uint8_t _enable);
|
||||
/**********************************************************************************
|
||||
* @name kvmv_read_img
|
||||
* @author Sipeed BuGu
|
||||
* @date 2024/10/25
|
||||
* @version R1.0
|
||||
* @brief Acquire the encoded image with auto init
|
||||
* @param _width @input: Output image width
|
||||
* @param _height @input: Output image height
|
||||
* @param _type @input: Encode type
|
||||
* @param _qlty @input: MJPEG: (50-100) | H264: (500-10000)
|
||||
* @param _pp_kvm_data @output: Encode data
|
||||
* @param _p_kvmv_data_size @output: Encode data size
|
||||
* @return
|
||||
-7: HDMI INPUT RES ERROR
|
||||
-6: Unsupported resolution, please modify it in the host settings.
|
||||
-5: Retrieving image, please wait
|
||||
-4: Modifying image resolution, please wait
|
||||
-3: img buffer full
|
||||
-2: VENC Error
|
||||
-1: No images were acquired
|
||||
0: Acquire MJPEG encoded images
|
||||
1: Acquire H264 encoded images(SPS)[Deprecated]
|
||||
2: Acquire H264 encoded images(PPS)[Deprecated]
|
||||
3: Acquire H264 encoded images(I)
|
||||
4: Acquire H264 encoded images(P)
|
||||
5: IMG not changed
|
||||
**********************************************************************************/
|
||||
int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _qlty, uint8_t** _pp_kvm_data, uint32_t* _p_kvmv_data_size);
|
||||
int free_kvmv_data(uint8_t ** _pp_kvm_data);
|
||||
void free_all_kvmv_data();
|
||||
void set_h264_gop(uint8_t _gop);
|
||||
void set_frame_detact(uint8_t _frame_detact);
|
||||
void kvmv_deinit();
|
||||
uint8_t kvmv_hdmi_control(uint8_t _en);
|
||||
void kvmv_init(uint8_t debug_info);
|
||||
void kvmv_deinit(void);
|
||||
|
||||
// type: 0 = MJPEG, 1 = H.264. A zero width/height follows the input size.
|
||||
// quality: MJPEG 50..100, H.264 500..10000 Kbps. fps: 1..60.
|
||||
// Returns -7 input error, -6 unsupported size, -5 busy, -4 reconfiguring,
|
||||
// -3 allocation failure, -2 encoder error, -1 no frame, 0 JPEG, 3 IDR,
|
||||
// 4 P-frame, or 5 unchanged JPEG. Release every successful frame once.
|
||||
int kvmv_read_img(uint16_t width, uint16_t height, uint8_t type,
|
||||
uint16_t quality, uint16_t fps, kvmv_frame_t *frame);
|
||||
// Frees the copied bytes and releases the encoder lease. Safe after shutdown.
|
||||
void kvmv_release_frame(kvmv_frame_t *frame);
|
||||
void kvmv_stop_stream(uint8_t type);
|
||||
void set_h264_gop(uint8_t gop);
|
||||
void set_frame_detact(uint8_t interval);
|
||||
uint8_t kvmv_hdmi_control(uint8_t enable);
|
||||
uint8_t kvmv_hdmi_signal_active(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"NanoKVM-Server/logger"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/router"
|
||||
"NanoKVM-Server/service/storage/diskfs"
|
||||
"NanoKVM-Server/service/vm"
|
||||
"NanoKVM-Server/service/vm/jiggler"
|
||||
"NanoKVM-Server/utils"
|
||||
@@ -49,6 +50,11 @@ func initialize() {
|
||||
// run mouse jiggler
|
||||
jiggler.GetJiggler().Run()
|
||||
|
||||
// A medium left mounted by a crash or a restart keeps its loop device
|
||||
// busy; nothing can be mounted by this process yet, so whatever is there
|
||||
// is stale.
|
||||
diskfs.CleanupStale()
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
|
||||
go func() {
|
||||
@@ -123,5 +129,6 @@ func run() {
|
||||
}
|
||||
|
||||
func dispose() {
|
||||
diskfs.CleanupStale()
|
||||
common.GetKvmVision().Close()
|
||||
}
|
||||
|
||||
@@ -22,8 +22,18 @@ const (
|
||||
)
|
||||
|
||||
type Principal struct {
|
||||
Username string
|
||||
Role authn.Role
|
||||
Username string
|
||||
Role authn.Role
|
||||
Permissions []authn.Permission
|
||||
}
|
||||
|
||||
// Has reports whether the principal effectively holds the permission.
|
||||
// Administrators always do.
|
||||
func (p Principal) Has(permission authn.Permission) bool {
|
||||
if p.Role == authn.RoleAdmin {
|
||||
return true
|
||||
}
|
||||
return authn.Has(p.Permissions, permission)
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
@@ -48,8 +58,34 @@ func CheckToken() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
requestContext, cancel := context.WithCancel(c.Request.Context())
|
||||
sessionHandle := newSessionHandle()
|
||||
requestContext = WithSessionHandle(requestContext, sessionHandle)
|
||||
c.Request = c.Request.WithContext(requestContext)
|
||||
unregister := activeSessions.register(principal.Username, cancel)
|
||||
channel := SessionChannelForPath(c.Request.URL.Path)
|
||||
unregister := activeSessions.register(
|
||||
principal.Username,
|
||||
cancel,
|
||||
sessionHandle,
|
||||
channel,
|
||||
)
|
||||
// The principal was resolved before this session could be found by a
|
||||
// revocation, so a grant edit landing in between would miss it. Now that
|
||||
// it is registered, any later edit drops it; re-reading the account picks
|
||||
// up any earlier one. Only the long-lived channels are dropped by a grant
|
||||
// edit, so a short request keeps the principal it was authorized with.
|
||||
if channel != "" {
|
||||
user, err := authn.DefaultStore.ValidateToken(principal.Username, token.TokenVersion)
|
||||
if err != nil {
|
||||
unregister()
|
||||
cancel()
|
||||
abortUnauthorized(c)
|
||||
return
|
||||
}
|
||||
principal.Role = user.Role
|
||||
principal.Permissions = authn.EffectivePermissions(user)
|
||||
c.Set(principalContextKey, principal)
|
||||
}
|
||||
|
||||
timer := time.AfterFunc(time.Until(token.ExpiresAt.Time), cancel)
|
||||
go watchSessionState(requestContext, cancel, principal.Username, token.TokenVersion, sessionRecheckDelay)
|
||||
defer func() {
|
||||
@@ -90,8 +126,31 @@ func RequireRole(roles ...authn.Role) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
if _, ok = allowed[principal.Role]; !ok {
|
||||
c.JSON(http.StatusForbidden, "forbidden")
|
||||
c.Abort()
|
||||
abortForbidden(c)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequirePermission allows the request when the principal holds at least one
|
||||
// of the listed permissions. Administrators implicitly hold every permission.
|
||||
func RequirePermission(permissions ...authn.Permission) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
principal, ok := CurrentPrincipal(c)
|
||||
if !ok {
|
||||
abortUnauthorized(c)
|
||||
return
|
||||
}
|
||||
allowed := false
|
||||
for _, permission := range permissions {
|
||||
if principal.Has(permission) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
abortForbidden(c)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
@@ -138,7 +197,7 @@ func CheckTokenOrLoopbackInternalToken() gin.HandlerFunc {
|
||||
func authenticate(c *gin.Context) (Principal, *Token, bool) {
|
||||
conf := config.GetInstance()
|
||||
if conf.Authentication == "disable" {
|
||||
return Principal{Username: "admin", Role: authn.RoleAdmin}, nil, true
|
||||
return Principal{Username: "admin", Role: authn.RoleAdmin, Permissions: authn.PermissionIDs()}, nil, true
|
||||
}
|
||||
|
||||
cookie, err := c.Cookie(CookieName)
|
||||
@@ -154,7 +213,11 @@ func authenticate(c *gin.Context) (Principal, *Token, bool) {
|
||||
log.Debugf("validate session for %q: %s", token.Username, err)
|
||||
return Principal{}, nil, false
|
||||
}
|
||||
return Principal{Username: user.Username, Role: user.Role}, token, true
|
||||
return Principal{
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
Permissions: authn.EffectivePermissions(user),
|
||||
}, token, true
|
||||
}
|
||||
|
||||
func abortUnauthorized(c *gin.Context) {
|
||||
@@ -162,6 +225,11 @@ func abortUnauthorized(c *gin.Context) {
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
func abortForbidden(c *gin.Context) {
|
||||
c.JSON(http.StatusForbidden, "forbidden")
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
func GenerateJWT(username string, tokenVersion uint64) (string, error) {
|
||||
conf := config.GetInstance()
|
||||
now := time.Now()
|
||||
|
||||
@@ -114,7 +114,7 @@ func TestParseJWTRejectsExpiredToken(t *testing.T) {
|
||||
func TestRevokeUserSessionsCancelsActiveRequests(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
unregister := activeSessions.register("alice", cancel)
|
||||
unregister := activeSessions.register("alice", cancel, newSessionHandle(), "")
|
||||
defer unregister()
|
||||
|
||||
RevokeUserSessions("alice")
|
||||
|
||||
@@ -15,21 +15,78 @@ import (
|
||||
|
||||
const SessionRevokedCloseCode = 4401
|
||||
|
||||
// SessionRefreshedCloseCode closes connections whose permissions changed while
|
||||
// the underlying login is still valid. Clients are expected to reconnect and
|
||||
// refresh their account state instead of returning to the login page.
|
||||
const SessionRefreshedCloseCode = 4403
|
||||
|
||||
type sessionHandleKey struct{}
|
||||
|
||||
// Long-lived connection channels, used to drop only the connections affected
|
||||
// by a permission change instead of every session of the user.
|
||||
const (
|
||||
SessionChannelConsole = "console" // /api/ws (input events, status pushes)
|
||||
SessionChannelStream = "stream" // /api/stream/* (video signaling and frames)
|
||||
SessionChannelTerminal = "terminal" // /api/vm/terminal
|
||||
SessionChannelAi = "ai" // /api/picoclaw/gateway/ws
|
||||
)
|
||||
|
||||
// SessionChannelForPath classifies long-lived endpoints; everything else
|
||||
// returns an empty channel and is only affected by hard revocation.
|
||||
func SessionChannelForPath(path string) string {
|
||||
switch {
|
||||
case path == "/api/ws":
|
||||
return SessionChannelConsole
|
||||
case strings.HasPrefix(path, "/api/stream/"):
|
||||
return SessionChannelStream
|
||||
case path == "/api/vm/terminal":
|
||||
return SessionChannelTerminal
|
||||
case path == "/api/picoclaw/gateway/ws":
|
||||
return SessionChannelAi
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// sessionHandle lets a revocation pick the WebSocket close code before the
|
||||
// request context is canceled.
|
||||
type sessionHandle struct {
|
||||
closeCode atomic.Int32
|
||||
}
|
||||
|
||||
type sessionEntry struct {
|
||||
cancel context.CancelFunc
|
||||
handle *sessionHandle
|
||||
channel string
|
||||
}
|
||||
|
||||
type sessionRegistry struct {
|
||||
mutex sync.Mutex
|
||||
nextID atomic.Uint64
|
||||
byUserID map[string]map[uint64]context.CancelFunc
|
||||
byUserID map[string]map[uint64]*sessionEntry
|
||||
}
|
||||
|
||||
var activeSessions = &sessionRegistry{byUserID: make(map[string]map[uint64]context.CancelFunc)}
|
||||
var activeSessions = &sessionRegistry{byUserID: make(map[string]map[uint64]*sessionEntry)}
|
||||
|
||||
func (r *sessionRegistry) register(username string, cancel context.CancelFunc) func() {
|
||||
func newSessionHandle() *sessionHandle {
|
||||
handle := &sessionHandle{}
|
||||
handle.closeCode.Store(SessionRevokedCloseCode)
|
||||
return handle
|
||||
}
|
||||
|
||||
// WithSessionHandle attaches the handle to the request context so
|
||||
// WatchWebSocket can honor the close code chosen at revocation time.
|
||||
func WithSessionHandle(ctx context.Context, handle *sessionHandle) context.Context {
|
||||
return context.WithValue(ctx, sessionHandleKey{}, handle)
|
||||
}
|
||||
|
||||
func (r *sessionRegistry) register(username string, cancel context.CancelFunc, handle *sessionHandle, channel string) func() {
|
||||
id := r.nextID.Add(1)
|
||||
r.mutex.Lock()
|
||||
if r.byUserID[username] == nil {
|
||||
r.byUserID[username] = make(map[uint64]context.CancelFunc)
|
||||
r.byUserID[username] = make(map[uint64]*sessionEntry)
|
||||
}
|
||||
r.byUserID[username][id] = cancel
|
||||
r.byUserID[username][id] = &sessionEntry{cancel: cancel, handle: handle, channel: channel}
|
||||
r.mutex.Unlock()
|
||||
|
||||
return func() {
|
||||
@@ -42,26 +99,79 @@ func (r *sessionRegistry) register(username string, cancel context.CancelFunc) f
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeUserSessions hard-drops every active session of the user. The login
|
||||
// is expected to be invalid afterwards; connections close as unauthorized.
|
||||
func RevokeUserSessions(username string) {
|
||||
dropUserSessions(username, SessionRevokedCloseCode, nil)
|
||||
}
|
||||
|
||||
// RefreshUserSessions drops the user's long-lived connections on the given
|
||||
// channels while their login stays valid, so those clients reconnect under
|
||||
// the updated permissions. Unrelated connections (e.g. the video stream when
|
||||
// only console control was revoked) stay untouched.
|
||||
func RefreshUserSessions(username string, channels ...string) {
|
||||
dropUserSessions(username, SessionRefreshedCloseCode, channels)
|
||||
}
|
||||
|
||||
func dropUserSessions(username string, closeCode int, channels []string) {
|
||||
activeSessions.mutex.Lock()
|
||||
sessions := activeSessions.byUserID[username]
|
||||
delete(activeSessions.byUserID, username)
|
||||
if channels == nil {
|
||||
delete(activeSessions.byUserID, username)
|
||||
}
|
||||
|
||||
dropped := make([]*sessionEntry, 0, len(sessions))
|
||||
for id, entry := range sessions {
|
||||
if !channelMatches(entry.channel, channels) {
|
||||
continue
|
||||
}
|
||||
dropped = append(dropped, entry)
|
||||
if channels != nil {
|
||||
delete(sessions, id)
|
||||
}
|
||||
}
|
||||
if channels != nil && len(sessions) == 0 {
|
||||
delete(activeSessions.byUserID, username)
|
||||
}
|
||||
activeSessions.mutex.Unlock()
|
||||
|
||||
for _, cancel := range sessions {
|
||||
cancel()
|
||||
for _, entry := range dropped {
|
||||
entry.handle.closeCode.Store(int32(closeCode))
|
||||
entry.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// channelMatches reports whether the entry is affected. A nil channel list
|
||||
// means a hard revocation, which drops everything.
|
||||
func channelMatches(channel string, channels []string) bool {
|
||||
if channels == nil {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range channels {
|
||||
if channel == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func WatchWebSocket(ctx context.Context, connection *websocket.Conn) func() {
|
||||
stopped := make(chan struct{})
|
||||
var stopOnce sync.Once
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
closeCode := SessionRevokedCloseCode
|
||||
reason := "session expired or revoked"
|
||||
if handle, ok := ctx.Value(sessionHandleKey{}).(*sessionHandle); ok {
|
||||
if code := int(handle.closeCode.Load()); code == SessionRefreshedCloseCode {
|
||||
closeCode = code
|
||||
reason = "permissions changed"
|
||||
}
|
||||
}
|
||||
_ = connection.WriteControl(
|
||||
websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(SessionRevokedCloseCode, "session expired or revoked"),
|
||||
websocket.FormatCloseMessage(closeCode, reason),
|
||||
time.Now().Add(2*time.Second),
|
||||
)
|
||||
_ = connection.Close()
|
||||
|
||||
@@ -6,8 +6,9 @@ type LoginReq struct {
|
||||
}
|
||||
|
||||
type GetAccountRsp struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
type ChangePasswordReq struct {
|
||||
@@ -20,10 +21,25 @@ type IsPasswordUpdatedRsp struct {
|
||||
}
|
||||
|
||||
type UserInfo struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SystemAccount bool `json:"systemAccount,omitempty"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Permissions []string `json:"permissions"`
|
||||
SystemAccount bool `json:"systemAccount,omitempty"`
|
||||
}
|
||||
|
||||
type ListPermissionsRsp struct {
|
||||
Permissions []PermissionInfo `json:"permissions"`
|
||||
}
|
||||
|
||||
// PermissionInfo describes one grantable permission: the group it belongs to,
|
||||
// how far it reaches, and the grants it implies. The client renders the editor
|
||||
// from this catalog instead of keeping its own copy of the model.
|
||||
type PermissionInfo struct {
|
||||
ID string `json:"id"`
|
||||
Group string `json:"group"`
|
||||
Risk string `json:"risk"`
|
||||
Implies []string `json:"implies,omitempty"`
|
||||
}
|
||||
|
||||
type ListUsersRsp struct {
|
||||
@@ -31,12 +47,23 @@ type ListUsersRsp struct {
|
||||
}
|
||||
|
||||
type CreateUserReq struct {
|
||||
Username string `json:"username" validate:"required"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
Role string `json:"role" validate:"required"`
|
||||
Username string `json:"username" validate:"required"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
Role string `json:"role" validate:"required"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
type UpdateUserReq struct {
|
||||
Role *string `json:"role"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Role *string `json:"role"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Permissions *[]string `json:"permissions"`
|
||||
// RotateMcpKey asks to regenerate the device-wide MCP API key when this
|
||||
// change takes the mcp grant away. The administrator decides: rotating
|
||||
// disconnects every MCP client, keeping it leaves a copied key working.
|
||||
RotateMcpKey bool `json:"rotateMcpKey"`
|
||||
}
|
||||
|
||||
// UpdateUserRsp says whether the change regenerated the MCP API key.
|
||||
type UpdateUserRsp struct {
|
||||
McpKeyRotated bool `json:"mcpKeyRotated"`
|
||||
}
|
||||
|
||||
@@ -13,4 +13,6 @@ type StatusImageRsp struct {
|
||||
Status string `json:"status"`
|
||||
File string `json:"file"`
|
||||
Percentage string `json:"percentage"`
|
||||
Loaded int64 `json:"loaded"` // bytes transferred so far
|
||||
Total int64 `json:"total"` // expected bytes, 0 when unknown
|
||||
}
|
||||
|
||||
100
server/proto/media.go
Normal file
100
server/proto/media.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package proto
|
||||
|
||||
import "time"
|
||||
|
||||
// Virtual media wire types. A medium is named by a type plus a path relative
|
||||
// to the media directory: the server resolves the id itself, so a request
|
||||
// never carries an absolute path it could be talked into deleting.
|
||||
|
||||
const (
|
||||
MediaTypeImage = "image"
|
||||
MediaTypePartition = "partition"
|
||||
)
|
||||
|
||||
type MediaItem struct {
|
||||
Type string `json:"type"`
|
||||
Rel string `json:"rel"`
|
||||
Name string `json:"name"`
|
||||
Size uint64 `json:"size"`
|
||||
FS string `json:"fs"`
|
||||
Browsable bool `json:"browsable"`
|
||||
Attached bool `json:"attached"`
|
||||
Modified time.Time `json:"modifiedAt"`
|
||||
}
|
||||
|
||||
type MediaCaps struct {
|
||||
Loop bool `json:"loop"`
|
||||
}
|
||||
|
||||
type GetMediaRsp struct {
|
||||
Media []MediaItem `json:"media"`
|
||||
Caps MediaCaps `json:"capabilities"`
|
||||
}
|
||||
|
||||
type MediaStateRsp struct {
|
||||
State string `json:"state"` // none | image | partition | unsupported
|
||||
Type string `json:"type"` // type of the attached medium
|
||||
Rel string `json:"rel"` // rel of the attached medium, when it is an image
|
||||
HidOnly bool `json:"hidOnly"` // the gadget has no mass storage at all
|
||||
Open []string `json:"open"` // media the file manager currently holds open
|
||||
Cdrom bool `json:"cdrom"` // the attached medium is served as a CD-ROM
|
||||
// LocalWritable is false while /data is mounted read-only, which is what
|
||||
// the target machine owning the partition does. Every write would fail
|
||||
// with EROFS, so the UI disables them instead of asking.
|
||||
LocalWritable bool `json:"localWritable"`
|
||||
}
|
||||
|
||||
type MediaRefReq struct {
|
||||
Type string `json:"type" validate:"omitempty,oneof=image partition"`
|
||||
Rel string `json:"rel"`
|
||||
}
|
||||
|
||||
type MountMediaReq struct {
|
||||
Type string `json:"type" validate:"omitempty,oneof=image partition"`
|
||||
Rel string `json:"rel"`
|
||||
Cdrom bool `json:"cdrom"`
|
||||
// Force re-enumerates the gadget to take the medium back from a target
|
||||
// machine that is holding it. It costs the target machine its unflushed
|
||||
// writes, so the UI asks before setting it.
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
type EjectMediaReq struct {
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
type MediaStatusRsp struct {
|
||||
Status string `json:"status"`
|
||||
Name string `json:"name"`
|
||||
Percentage string `json:"percentage"`
|
||||
Loaded int64 `json:"loaded"` // bytes transferred so far
|
||||
Total int64 `json:"total"` // expected bytes, 0 when unknown
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type MediaFile struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Modified time.Time `json:"modifiedAt"`
|
||||
}
|
||||
|
||||
type GetMediaFilesRsp struct {
|
||||
Path string `json:"path"`
|
||||
Entries []MediaFile `json:"entries"`
|
||||
Free uint64 `json:"free"`
|
||||
Total uint64 `json:"total"`
|
||||
// Writing is true while the medium is seeing block level writes: a zero
|
||||
// length entry in that state is a transfer in progress, not an empty file.
|
||||
Writing bool `json:"writing"`
|
||||
}
|
||||
|
||||
type MediaPathReq struct {
|
||||
Type string `json:"type" validate:"omitempty,oneof=image partition"`
|
||||
Rel string `json:"rel"`
|
||||
Path string `json:"path" validate:"required"`
|
||||
// ExistsOK turns "the directory is already there" into success. An upload
|
||||
// of a whole tree recreates folders that may already exist on the medium,
|
||||
// and stopping at the first one would leave the tree half copied.
|
||||
ExistsOK bool `json:"existsOk"`
|
||||
}
|
||||
@@ -30,8 +30,8 @@ type GetGpioRsp struct {
|
||||
}
|
||||
|
||||
type SetScreenReq struct {
|
||||
Type string `validate:"required"` // resolution / fps / quality
|
||||
Value int `validate:"number"` // value
|
||||
Type string `validate:"required,oneof=type fps quality gop"`
|
||||
Value int `validate:"number"` // value
|
||||
}
|
||||
|
||||
type GetScriptsRsp struct {
|
||||
@@ -72,6 +72,10 @@ type GetVirtualDeviceRsp struct {
|
||||
|
||||
type UpdateVirtualDeviceReq struct {
|
||||
Device string `validate:"required"`
|
||||
// Enable turns the device on or off instead of toggling it. A caller that
|
||||
// says "enable" must not switch a running device off because it happened to
|
||||
// be already on.
|
||||
Enable *bool `json:"enable"`
|
||||
}
|
||||
|
||||
type UpdateVirtualDeviceRsp struct {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
func applicationRouter(r *gin.Engine) {
|
||||
service := application.NewService()
|
||||
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
|
||||
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermUpdate))
|
||||
|
||||
api.GET("/application/version", service.GetVersion) // get application version
|
||||
api.POST("/application/update", service.Update) // update application
|
||||
|
||||
@@ -24,6 +24,7 @@ func authRouter(r *gin.Engine) {
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
)
|
||||
admin.GET("/auth/permissions", service.ListPermissions)
|
||||
admin.GET("/auth/users", service.ListUsers)
|
||||
admin.POST("/auth/users", service.CreateUser)
|
||||
admin.PUT("/auth/users/:username", service.UpdateUser)
|
||||
|
||||
@@ -22,7 +22,7 @@ type setAIControlModeRequest struct {
|
||||
func controlRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *picoclaw.Service) {
|
||||
group := r.Group("/api/ai/control").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
middleware.RequirePermission(authn.PermAi),
|
||||
)
|
||||
group.GET("/status", func(c *gin.Context) {
|
||||
status, err := control.Status()
|
||||
|
||||
@@ -2,19 +2,25 @@ package router
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/service/download"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/download"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func downloadRouter(r *gin.Engine) {
|
||||
service := download.NewService()
|
||||
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
|
||||
// A signed URL that ends up on the data partition changes what this device
|
||||
// keeps, so it follows the library write grant.
|
||||
write := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermLibraryWrite))
|
||||
// Progress and availability are reads: a user who may attach a medium can
|
||||
// take /data away from the downloader, so they need to see whether one is
|
||||
// running, and reading the library is implied by every media grant.
|
||||
read := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermLibraryRead))
|
||||
|
||||
api.POST("/download/image", service.DownloadImage) // download image
|
||||
api.POST("/download/image/cancel", service.CancelDownloadImage) // cancel image download
|
||||
api.GET("/download/image/status", service.StatusImage) // download image
|
||||
api.GET("/download/image/enabled", service.ImageEnabled) // download image
|
||||
api.POST("/download/file", service.DownloadImageFile) // download image
|
||||
write.POST("/download/image", service.DownloadImage) // download image
|
||||
write.POST("/download/image/cancel", service.CancelDownloadImage) // cancel image download
|
||||
read.GET("/download/image/status", service.StatusImage) // download image
|
||||
read.GET("/download/image/enabled", service.ImageEnabled) // download image
|
||||
write.POST("/download/file", service.DownloadImageFile) // upload image file
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func extensionsRouter(r *gin.Engine) {
|
||||
api := r.Group("/api/extensions").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
middleware.RequirePermission(authn.PermTailscale),
|
||||
)
|
||||
|
||||
ts := tailscale.NewService()
|
||||
|
||||
@@ -16,26 +16,24 @@ func HIDLoopbackHTTPAllowedPaths() []string {
|
||||
|
||||
func hidRouter(r *gin.Engine) {
|
||||
service := hid.NewService()
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
view := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermConsoleView))
|
||||
control := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermConsoleControl))
|
||||
system := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermDevice))
|
||||
localAPI := r.Group("/api/internal").Use(middleware.CheckLoopbackInternalToken())
|
||||
|
||||
api.POST("/hid/paste", service.Paste) // paste
|
||||
control.POST("/hid/paste", service.Paste) // paste
|
||||
|
||||
api.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
|
||||
api.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // get shortcut leader key
|
||||
view.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
|
||||
view.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // get shortcut leader key
|
||||
|
||||
api.GET("/hid/mode", service.GetHidMode) // get hid mode
|
||||
api.GET("/hid/leds", service.GetKeyboardLedStatus)
|
||||
view.GET("/hid/mode", service.GetHidMode) // get hid mode
|
||||
view.GET("/hid/leds", service.GetKeyboardLedStatus)
|
||||
|
||||
admin := r.Group("/api").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
)
|
||||
admin.POST("/hid/mode", service.SetHidMode) // set hid mode
|
||||
admin.POST("/hid/reset", service.ResetHid) // reset hid
|
||||
admin.POST("/hid/shortcut", service.AddShortcut)
|
||||
admin.DELETE("/hid/shortcut", service.DeleteShortcut)
|
||||
admin.POST("/hid/shortcut/leader-key", service.SetLeaderKey)
|
||||
system.POST("/hid/mode", service.SetHidMode) // set hid mode
|
||||
system.POST("/hid/reset", service.ResetHid) // reset hid
|
||||
system.POST("/hid/shortcut", service.AddShortcut)
|
||||
system.DELETE("/hid/shortcut", service.DeleteShortcut)
|
||||
system.POST("/hid/shortcut/leader-key", service.SetLeaderKey)
|
||||
|
||||
localAPI.POST("/usb/recover", service.RecoverUSB)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/auth"
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/hid"
|
||||
mcpservice "NanoKVM-Server/service/mcp"
|
||||
@@ -24,9 +25,11 @@ func mcpRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *pic
|
||||
picoclawService.PublishControlModeChangedFrom(status, "mcp_config")
|
||||
},
|
||||
)
|
||||
auth.APIKeyRotator = mcpservice.RotateAPIKey
|
||||
|
||||
management := r.Group("/api/mcp").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
middleware.RequirePermission(authn.PermMcp),
|
||||
)
|
||||
management.GET("/config", service.GetConfig)
|
||||
management.POST("/config", service.SetConfig)
|
||||
|
||||
@@ -14,21 +14,18 @@ func networkRouter(r *gin.Engine) {
|
||||
r.POST("/api/network/wifi", service.ConnectWifiNoAuth) // connect Wi-Fi without auth (only available in ap mode)
|
||||
r.POST("/api/network/wifi/verify", service.VerifyApLogin) // verify ap login
|
||||
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
power := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermPower))
|
||||
network := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermNetwork))
|
||||
|
||||
api.POST("/network/wol", service.WakeOnLAN) // wake on lan
|
||||
api.GET("/network/wol/mac", service.GetMac) // get mac list
|
||||
power.POST("/network/wol", service.WakeOnLAN) // wake on lan
|
||||
power.GET("/network/wol/mac", service.GetMac) // get mac list
|
||||
|
||||
admin := r.Group("/api").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
)
|
||||
admin.DELETE("/network/wol/mac", service.DeleteMac) // delete mac
|
||||
admin.POST("/network/wol/mac/name", service.SetMacName) // set mac name
|
||||
power.DELETE("/network/wol/mac", service.DeleteMac) // delete mac
|
||||
power.POST("/network/wol/mac/name", service.SetMacName) // set mac name
|
||||
|
||||
admin.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
|
||||
admin.POST("/network/wifi/connect", service.ConnectWifi) // connect Wi-Fi
|
||||
admin.POST("/network/wifi/disconnect", service.DisconnectWifi) // disconnect Wi-Fi
|
||||
admin.GET("/network/dns", service.GetDNS) // get DNS configuration
|
||||
admin.POST("/network/dns", service.SetDNS) // set DNS configuration
|
||||
network.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
|
||||
network.POST("/network/wifi/connect", service.ConnectWifi) // connect Wi-Fi
|
||||
network.POST("/network/wifi/disconnect", service.DisconnectWifi) // disconnect Wi-Fi
|
||||
network.GET("/network/dns", service.GetDNS) // get DNS configuration
|
||||
network.POST("/network/dns", service.SetDNS) // set DNS configuration
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func PicoclawLoopbackHTTPAllowedPaths() []string {
|
||||
func picoclawRouter(r *gin.Engine, service *picoclaw.Service) {
|
||||
frontendAPI := r.Group(picoclawBasePath).Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
middleware.RequirePermission(authn.PermAi),
|
||||
)
|
||||
localAPI := r.Group(picoclawBasePath).Use(middleware.CheckLoopbackInternalToken())
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"NanoKVM-Server/service/auth"
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/picoclaw"
|
||||
"NanoKVM-Server/service/ws"
|
||||
"NanoKVM-Server/webcache"
|
||||
|
||||
"github.com/gin-gonic/contrib/static"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -28,6 +31,7 @@ func web(r *gin.Engine) {
|
||||
execDir := filepath.Dir(execPath)
|
||||
webPath := fmt.Sprintf("%s/web", execDir)
|
||||
|
||||
r.Use(webcache.Headers(webPath))
|
||||
r.Use(static.Serve("/", static.LocalFile(webPath, true)))
|
||||
}
|
||||
|
||||
@@ -35,6 +39,8 @@ func server(r *gin.Engine) {
|
||||
control := controlmode.GetManager()
|
||||
picoclawService := picoclaw.NewService(control)
|
||||
|
||||
auth.SessionNotifier = ws.GetManager().NotifyUser
|
||||
|
||||
authRouter(r)
|
||||
applicationRouter(r)
|
||||
vmRouter(r)
|
||||
|
||||
@@ -1,20 +1,50 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/storage"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func storageRouter(r *gin.Engine) {
|
||||
service := storage.NewService()
|
||||
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
|
||||
// Reading the library is the one grant the other media grants imply, so it
|
||||
// alone gates every read: it is what makes the storage page reachable.
|
||||
read := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermLibraryRead))
|
||||
// Attaching a medium is what hands it to the target machine; changing the
|
||||
// library is what changes what this device keeps.
|
||||
attach := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermMediumAttach))
|
||||
write := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermLibraryWrite))
|
||||
// The file handlers serve an image and the shared partition through the same
|
||||
// path: the first is library content, the second is the surface the target
|
||||
// machine reads. Which grant applies follows the medium in the request, so
|
||||
// the route admits either and the handler narrows the pair.
|
||||
files := r.Group("/api").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequirePermission(authn.PermLibraryWrite, authn.PermMediumAttach),
|
||||
)
|
||||
|
||||
api.GET("/storage/image", service.GetImages) // get image list
|
||||
api.GET("/storage/image/mounted", service.GetMountedImage) // get mounted image
|
||||
api.POST("/storage/image/mount", service.MountImage) // mount image
|
||||
api.GET("/storage/cdrom", service.GetCdRom) // get CD-ROM flag
|
||||
api.POST("/storage/image/delete", service.DeleteImage) // delete image
|
||||
// Virtual media: the disk images the emulated USB disk exposes, the files
|
||||
// inside them, and the shared partition the target machine sees.
|
||||
read.GET("/storage/media", service.GetMedia) // list media
|
||||
read.GET("/storage/media/state", service.GetMediaState) // what the target machine sees
|
||||
read.GET("/storage/media/status", service.GetMediaStatus) // transfer progress
|
||||
read.GET("/storage/media/download", service.DownloadMedia) // download a whole image
|
||||
read.GET("/storage/media/files", service.ListMediaFiles) // list files inside a medium
|
||||
read.GET("/storage/media/files/download", service.DownloadMediaFile)
|
||||
attach.POST("/storage/media/mount", service.MountMedia) // attach a medium to the target machine
|
||||
attach.POST("/storage/media/eject", service.EjectMedia) // release the medium
|
||||
write.POST("/storage/media/delete", service.DeleteMedia) // delete a medium
|
||||
files.POST("/storage/media/files/upload", service.UploadMediaFile)
|
||||
files.POST("/storage/media/files/mkdir", service.MkdirMediaFile)
|
||||
files.POST("/storage/media/files/delete", service.DeleteMediaFile)
|
||||
|
||||
// The pre-2.6 image API, kept for scripts written against it.
|
||||
read.GET("/storage/image", service.GetImages) // get image list
|
||||
read.GET("/storage/image/mounted", service.GetMountedImage) // get mounted image
|
||||
read.GET("/storage/cdrom", service.GetCdRom) // get CD-ROM flag
|
||||
attach.POST("/storage/image/mount", service.MountImage) // mount image
|
||||
write.POST("/storage/image/delete", service.DeleteImage) // delete image
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/stream/direct"
|
||||
"NanoKVM-Server/service/stream/mjpeg"
|
||||
@@ -10,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
func streamRouter(r *gin.Engine) {
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermConsoleView))
|
||||
|
||||
api.GET("/stream/mjpeg", mjpeg.Connect) // mjpeg stream
|
||||
api.POST("/stream/mjpeg/detect", mjpeg.UpdateFrameDetect) // update frame detect
|
||||
|
||||
@@ -11,72 +11,95 @@ import (
|
||||
func vmRouter(r *gin.Engine) {
|
||||
service := vm.NewService()
|
||||
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
admin := r.Group("/api").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
)
|
||||
use := func(permission authn.Permission) gin.IRoutes {
|
||||
return r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(permission))
|
||||
}
|
||||
// The app shell reads the site title on every page, including the pages an
|
||||
// account without console access can open, so it asks for no feature grant.
|
||||
base := r.Group("/api").Use(middleware.CheckToken())
|
||||
view := use(authn.PermConsoleView)
|
||||
control := use(authn.PermConsoleControl)
|
||||
power := use(authn.PermPower)
|
||||
terminal := use(authn.PermTerminal)
|
||||
device := use(authn.PermDevice)
|
||||
network := use(authn.PermNetwork)
|
||||
|
||||
api.GET("/vm/info", service.GetInfo) // get device information
|
||||
api.GET("/vm/hardware", service.GetHardware) // get hardware version
|
||||
view.GET("/vm/info", service.GetInfo) // get device information
|
||||
view.GET("/vm/hardware", service.GetHardware) // get hardware version
|
||||
|
||||
api.POST("/vm/gpio", service.SetGpio) // update gpio
|
||||
api.GET("/vm/gpio", service.GetGpio) // get gpio
|
||||
api.POST("/vm/screen", service.SetScreen) // update screen
|
||||
power.POST("/vm/gpio", service.SetGpio) // update gpio
|
||||
power.GET("/vm/gpio", service.GetGpio) // get gpio
|
||||
|
||||
api.GET("/vm/input-region", service.GetInputRegion)
|
||||
api.POST("/vm/input-region", service.SetInputRegion)
|
||||
api.GET("/vm/input-resolution", service.GetInputResolution)
|
||||
// Video encoding and capture recovery are view operations: a user who may
|
||||
// only watch still has to be able to pick a quality their link can carry,
|
||||
// and every account holds console.view. Mapping pointer motion onto the
|
||||
// target screen stays console.control.
|
||||
view.POST("/vm/screen", service.SetScreen) // update screen
|
||||
|
||||
admin.GET("/vm/terminal", service.Terminal) // web terminal
|
||||
view.GET("/vm/input-region", service.GetInputRegion)
|
||||
control.POST("/vm/input-region", service.SetInputRegion)
|
||||
view.GET("/vm/input-resolution", service.GetInputResolution)
|
||||
|
||||
admin.GET("/vm/script", service.GetScripts) // get script
|
||||
admin.POST("/vm/script/upload", service.UploadScript) // upload script
|
||||
admin.POST("/vm/script/run", service.RunScript) // run script
|
||||
admin.DELETE("/vm/script", service.DeleteScript) // delete script
|
||||
terminal.GET("/vm/terminal", service.Terminal) // web terminal
|
||||
|
||||
admin.GET("/vm/device/virtual", service.GetVirtualDevice) // get virtual device
|
||||
admin.POST("/vm/device/virtual", service.UpdateVirtualDevice) // update virtual device
|
||||
terminal.GET("/vm/script", service.GetScripts) // get script
|
||||
terminal.POST("/vm/script/upload", service.UploadScript) // upload script
|
||||
terminal.POST("/vm/script/run", service.RunScript) // run script
|
||||
terminal.DELETE("/vm/script", service.DeleteScript) // delete script
|
||||
|
||||
admin.GET("/vm/memory/limit", service.GetMemoryLimit) // get memory limit
|
||||
admin.POST("/vm/memory/limit", service.SetMemoryLimit) // set memory limit
|
||||
// The emulated USB devices are device settings, not media or network ones:
|
||||
// the virtual disk hands the raw data partition to the target machine and the
|
||||
// RNDIS adapter hands it a network interface, so neither belongs to the
|
||||
// media library the disk serves or to the network settings of this side.
|
||||
device.GET("/vm/device/virtual", service.GetVirtualDevice) // get virtual device
|
||||
device.POST("/vm/device/virtual", service.UpdateVirtualDevice) // update virtual device
|
||||
|
||||
admin.GET("/vm/oled", service.GetOLED) // get OLED configuration
|
||||
admin.POST("/vm/oled", service.SetOLED) // set OLED configuration
|
||||
device.GET("/vm/memory/limit", service.GetMemoryLimit) // get memory limit
|
||||
device.POST("/vm/memory/limit", service.SetMemoryLimit) // set memory limit
|
||||
|
||||
device.GET("/vm/oled", service.GetOLED) // get OLED configuration
|
||||
device.POST("/vm/oled", service.SetOLED) // set OLED configuration
|
||||
|
||||
// Only supported by PCIe version
|
||||
api.GET("/vm/hdmi", service.GetHdmiState) // get HDMI state
|
||||
api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi
|
||||
admin.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi
|
||||
admin.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi
|
||||
admin.POST("/vm/hdmi/timeout", service.SetHdmiIdleTimeout)
|
||||
//
|
||||
// Toggling the HDMI capture engine is a device setting, but resetting it is
|
||||
// capture recovery: a watcher whose screen went black needs it to see
|
||||
// anything at all, the same way it needs to pick a video mode and a quality.
|
||||
// Enabling, disabling and the idle timeout stay with the device grant, since
|
||||
// they change how the device behaves for everyone rather than recover a
|
||||
// broken picture.
|
||||
view.GET("/vm/hdmi", service.GetHdmiState) // get HDMI state
|
||||
view.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi
|
||||
device.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi
|
||||
device.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi
|
||||
device.POST("/vm/hdmi/timeout", service.SetHdmiIdleTimeout)
|
||||
|
||||
admin.GET("/vm/ssh", service.GetSSHState) // get SSH state
|
||||
admin.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH
|
||||
admin.POST("/vm/ssh/disable", service.DisableSSH) // disable SSH
|
||||
device.GET("/vm/ssh", service.GetSSHState) // get SSH state
|
||||
device.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH
|
||||
device.POST("/vm/ssh/disable", service.DisableSSH) // disable SSH
|
||||
|
||||
admin.GET("/vm/swap", service.GetSwap) // get swap file size
|
||||
admin.POST("/vm/swap", service.SetSwap) // set swap file size
|
||||
device.GET("/vm/swap", service.GetSwap) // get swap file size
|
||||
device.POST("/vm/swap", service.SetSwap) // set swap file size
|
||||
|
||||
admin.GET("/vm/mouse-jiggler", service.GetMouseJiggler) // get mouse jiggler
|
||||
admin.POST("/vm/mouse-jiggler/", service.SetMouseJiggler) // set mouse jiggler
|
||||
device.GET("/vm/mouse-jiggler", service.GetMouseJiggler) // get mouse jiggler
|
||||
device.POST("/vm/mouse-jiggler/", service.SetMouseJiggler) // set mouse jiggler
|
||||
|
||||
api.GET("/vm/hostname", service.GetHostname) // Get Hostname
|
||||
admin.POST("/vm/hostname", service.SetHostname) // Set Hostname
|
||||
view.GET("/vm/hostname", service.GetHostname) // Get Hostname
|
||||
device.POST("/vm/hostname", service.SetHostname) // Set Hostname
|
||||
|
||||
api.GET("/vm/web-title", service.GetWebTitle) // Get web title
|
||||
admin.POST("/vm/web-title", service.SetWebTitle) // Set web title
|
||||
base.GET("/vm/web-title", service.GetWebTitle) // Get web title
|
||||
device.POST("/vm/web-title", service.SetWebTitle) // Set web title
|
||||
|
||||
admin.GET("/vm/mdns", service.GetMdnsState) // get mDNS state
|
||||
admin.POST("/vm/mdns/enable", service.EnableMdns) // enable mDNS
|
||||
admin.POST("/vm/mdns/disable", service.DisableMdns) // disable mDNS
|
||||
device.GET("/vm/mdns", service.GetMdnsState) // get mDNS state
|
||||
device.POST("/vm/mdns/enable", service.EnableMdns) // enable mDNS
|
||||
device.POST("/vm/mdns/disable", service.DisableMdns) // disable mDNS
|
||||
|
||||
admin.POST("/vm/tls", service.SetTls) // enable/disable TLS
|
||||
network.POST("/vm/tls", service.SetTls) // enable/disable TLS
|
||||
|
||||
admin.GET("/vm/autostart", service.GetAutostart) // get autostart list
|
||||
admin.GET("/vm/autostart/:name", service.GetAutostartContent) // get autostart content
|
||||
admin.DELETE("/vm/autostart/:name", service.DeleteAutostart) // delete autostart script
|
||||
admin.POST("/vm/autostart/:name", service.UploadAutostart) // upload autostart script
|
||||
terminal.GET("/vm/autostart", service.GetAutostart) // get autostart list
|
||||
terminal.GET("/vm/autostart/:name", service.GetAutostartContent) // get autostart content
|
||||
terminal.DELETE("/vm/autostart/:name", service.DeleteAutostart) // delete autostart script
|
||||
terminal.POST("/vm/autostart/:name", service.UploadAutostart) // upload autostart script
|
||||
|
||||
admin.POST("/vm/system/reboot", service.Reboot) // reboot system
|
||||
device.POST("/vm/system/reboot", service.Reboot) // reboot system
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@ package router
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/ws"
|
||||
)
|
||||
|
||||
func wsRouter(r *gin.Engine) {
|
||||
service := ws.NewService()
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermConsoleView))
|
||||
|
||||
api.GET("/ws", service.Connect)
|
||||
}
|
||||
|
||||
@@ -98,8 +98,9 @@ func (s *Service) GetAccount(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
rsp.OkRspWithData(c, &proto.GetAccountRsp{
|
||||
Username: principal.Username,
|
||||
Role: string(principal.Role),
|
||||
Username: principal.Username,
|
||||
Role: string(principal.Role),
|
||||
Permissions: permissionStrings(principal.Permissions),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,42 @@ import (
|
||||
|
||||
var errInvalidPassword = errors.New("invalid password")
|
||||
|
||||
// SessionNotifier pushes an account event to a user's live connections. It is
|
||||
// wired to the websocket manager during router setup; kept as a hook so this
|
||||
// package does not depend on the cgo-based HID stack.
|
||||
var SessionNotifier func(username, event, data string)
|
||||
|
||||
// APIKeyRotator replaces the device-wide MCP API key. It is wired by the MCP
|
||||
// router for the same reason as SessionNotifier.
|
||||
var APIKeyRotator func() error
|
||||
|
||||
// rotateAPIKeyIfLost regenerates the MCP API key when the account no longer
|
||||
// reaches it and the administrator asked for it. The key outlives the web
|
||||
// session, so a key the account copied keeps working until it is replaced;
|
||||
// replacing it disconnects every MCP client, so the account editor warns and
|
||||
// leaves the choice to the administrator. A failed rotation is reported so the
|
||||
// administrator learns that the old key still works.
|
||||
func rotateAPIKeyIfLost(previous, current []authn.Permission, requested bool) (bool, error) {
|
||||
if !authn.Has(previous, authn.PermMcp) || authn.Has(current, authn.PermMcp) {
|
||||
return false, nil
|
||||
}
|
||||
if !requested {
|
||||
log.Warnf("mcp grant revoked; the MCP API key was kept at the administrator's request")
|
||||
return false, nil
|
||||
}
|
||||
if APIKeyRotator == nil {
|
||||
return false, nil
|
||||
}
|
||||
if err := APIKeyRotator(); err != nil {
|
||||
log.Errorf("rotate MCP API key after revoking the mcp grant: %s", err)
|
||||
return false, errAPIKeyNotRotated
|
||||
}
|
||||
log.Infof("MCP API key rotated after the mcp grant was revoked")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var errAPIKeyNotRotated = errors.New("the change was saved, but the MCP API key could not be regenerated; regenerate it from the MCP settings")
|
||||
|
||||
func (s *Service) ListUsers(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
users, err := authn.DefaultStore.List()
|
||||
@@ -27,12 +63,28 @@ func (s *Service) ListUsers(c *gin.Context) {
|
||||
Username: user.Username,
|
||||
Role: string(user.Role),
|
||||
Enabled: user.Enabled,
|
||||
Permissions: permissionStrings(user.Permissions),
|
||||
SystemAccount: user.SystemAccount,
|
||||
})
|
||||
}
|
||||
rsp.OkRspWithData(c, &proto.ListUsersRsp{Users: result})
|
||||
}
|
||||
|
||||
func (s *Service) ListPermissions(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
catalog := authn.Catalog()
|
||||
permissions := make([]proto.PermissionInfo, 0, len(catalog))
|
||||
for _, descriptor := range catalog {
|
||||
permissions = append(permissions, proto.PermissionInfo{
|
||||
ID: string(descriptor.Permission),
|
||||
Group: string(descriptor.Group),
|
||||
Risk: string(descriptor.Risk),
|
||||
Implies: permissionStrings(descriptor.Implies),
|
||||
})
|
||||
}
|
||||
rsp.OkRspWithData(c, &proto.ListPermissionsRsp{Permissions: permissions})
|
||||
}
|
||||
|
||||
func (s *Service) CreateUser(c *gin.Context) {
|
||||
var req proto.CreateUserReq
|
||||
var rsp proto.Response
|
||||
@@ -45,10 +97,15 @@ func (s *Service) CreateUser(c *gin.Context) {
|
||||
rsp.ErrRsp(c, -2, err.Error())
|
||||
return
|
||||
}
|
||||
if err = authn.DefaultStore.Create(req.Username, password, authn.Role(req.Role)); err != nil {
|
||||
permissions, err := parsePermissions(req.Permissions)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -3, err.Error())
|
||||
return
|
||||
}
|
||||
if err = authn.DefaultStore.Create(req.Username, password, authn.Role(req.Role), permissions...); err != nil {
|
||||
rsp.ErrRsp(c, -4, err.Error())
|
||||
return
|
||||
}
|
||||
rsp.OkRsp(c)
|
||||
log.Infof("user created: %s (%s)", req.Username, req.Role)
|
||||
}
|
||||
@@ -56,7 +113,7 @@ func (s *Service) CreateUser(c *gin.Context) {
|
||||
func (s *Service) UpdateUser(c *gin.Context) {
|
||||
var req proto.UpdateUserReq
|
||||
var rsp proto.Response
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil || (req.Role == nil && req.Enabled == nil) {
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil || (req.Role == nil && req.Enabled == nil && req.Permissions == nil) {
|
||||
rsp.ErrRsp(c, -1, "invalid parameters")
|
||||
return
|
||||
}
|
||||
@@ -66,13 +123,58 @@ func (s *Service) UpdateUser(c *gin.Context) {
|
||||
role := authn.Role(*req.Role)
|
||||
patch.Role = &role
|
||||
}
|
||||
if req.Permissions != nil {
|
||||
permissions, err := parsePermissions(*req.Permissions)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, err.Error())
|
||||
return
|
||||
}
|
||||
patch.Permissions = &permissions
|
||||
}
|
||||
username := c.Param("username")
|
||||
if _, err := authn.DefaultStore.Update(principal.Username, username, patch); err != nil {
|
||||
// The store hands back the account as it was before and after this patch,
|
||||
// read under one lock, so a role change is handled exactly like editing the
|
||||
// permission set: the role resolves to its implicit grants and the
|
||||
// difference drives the refresh.
|
||||
previous, updated, err := authn.DefaultStore.Update(principal.Username, username, patch)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, err.Error())
|
||||
return
|
||||
}
|
||||
middleware.RevokeUserSessions(username)
|
||||
rsp.OkRsp(c)
|
||||
// A disabled account reaches nothing, whatever it stores.
|
||||
previousPermissions := enabledPermissions(previous)
|
||||
currentPermissions := enabledPermissions(updated)
|
||||
rotated, rotateErr := rotateAPIKeyIfLost(previousPermissions, currentPermissions, req.RotateMcpKey)
|
||||
|
||||
if updated.TokenVersion != previous.TokenVersion {
|
||||
// Disabling the account is the only change that invalidates the login.
|
||||
middleware.RevokeUserSessions(username)
|
||||
} else {
|
||||
// Every other change keeps the login valid: drop only the long-lived
|
||||
// connections whose in-memory state depends on what changed, and let the
|
||||
// frontend refresh its account state in place. Revoked grants have to
|
||||
// reconnect to stop using the feature, and granted ones have to reconnect
|
||||
// to pick up capabilities cached at connect time (the console socket
|
||||
// remembers whether it is read-only). Unrelated connections stay
|
||||
// untouched. A save that changes nothing stays silent.
|
||||
changed := permissionsChanged(previousPermissions, currentPermissions)
|
||||
roleChanged := updated.Role != previous.Role
|
||||
if (roleChanged || len(changed) > 0) && SessionNotifier != nil {
|
||||
// The role itself is rendered too (user management, the role label),
|
||||
// so the frontend refreshes even when the grants happen to match.
|
||||
// The notifier writes to every socket of the user, each with its own
|
||||
// write deadline; one stuck connection must not hold this request.
|
||||
go SessionNotifier(username, "auth", "refresh")
|
||||
}
|
||||
if channels := channelsForPermissions(changed); len(channels) > 0 {
|
||||
middleware.RefreshUserSessions(username, channels...)
|
||||
}
|
||||
}
|
||||
if rotateErr != nil {
|
||||
rsp.ErrRsp(c, -3, rotateErr.Error())
|
||||
return
|
||||
}
|
||||
rsp.OkRspWithData(c, &proto.UpdateUserRsp{McpKeyRotated: rotated})
|
||||
log.Infof("user updated: %s", username)
|
||||
}
|
||||
|
||||
@@ -80,12 +182,19 @@ func (s *Service) DeleteUser(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
principal, _ := middleware.CurrentPrincipal(c)
|
||||
username := c.Param("username")
|
||||
previous, _ := authn.DefaultStore.Get(username)
|
||||
if err := authn.DefaultStore.Delete(principal.Username, username); err != nil {
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
middleware.RevokeUserSessions(username)
|
||||
rsp.OkRsp(c)
|
||||
// DELETE carries no body, so the choice travels in the query string.
|
||||
rotated, err := rotateAPIKeyIfLost(enabledPermissions(previous), nil, c.Query("rotateMcpKey") == "true")
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, err.Error())
|
||||
return
|
||||
}
|
||||
rsp.OkRspWithData(c, &proto.UpdateUserRsp{McpKeyRotated: rotated})
|
||||
log.Infof("user deleted: %s", username)
|
||||
}
|
||||
|
||||
@@ -115,6 +224,77 @@ func (s *Service) ChangeUserPassword(c *gin.Context) {
|
||||
log.Infof("password reset for user: %s", username)
|
||||
}
|
||||
|
||||
func enabledPermissions(user *authn.User) []authn.Permission {
|
||||
if user == nil || !user.Enabled {
|
||||
return nil
|
||||
}
|
||||
return authn.EffectivePermissions(user)
|
||||
}
|
||||
|
||||
// permissionsChanged returns the grants that were added or removed. Both
|
||||
// directions matter: a revoked grant must stop reaching the feature, while a
|
||||
// granted one must reconnect to pick up state cached per connection.
|
||||
func permissionsChanged(oldPermissions, newPermissions []authn.Permission) []authn.Permission {
|
||||
changed := make([]authn.Permission, 0, len(oldPermissions))
|
||||
for _, old := range oldPermissions {
|
||||
if !authn.Has(newPermissions, old) {
|
||||
changed = append(changed, old)
|
||||
}
|
||||
}
|
||||
for _, current := range newPermissions {
|
||||
if !authn.Has(oldPermissions, current) {
|
||||
changed = append(changed, current)
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// channelsForPermissions maps permissions that changed to the long-lived
|
||||
// connection channels that must reconnect to take effect. Permissions without
|
||||
// connection state (library, network, ...) are enforced per-request and need no
|
||||
// teardown.
|
||||
func channelsForPermissions(permissions []authn.Permission) []string {
|
||||
channels := make([]string, 0, 2)
|
||||
for _, permission := range permissions {
|
||||
switch permission {
|
||||
case authn.PermConsoleView:
|
||||
// Losing the console grant closes the video stream, and gaining it (an
|
||||
// account promoted to administrator) keeps every console connection
|
||||
// from running under the grants it cached at connect time.
|
||||
channels = append(channels, middleware.SessionChannelConsole, middleware.SessionChannelStream)
|
||||
case authn.PermConsoleControl:
|
||||
channels = append(channels, middleware.SessionChannelConsole)
|
||||
case authn.PermTerminal:
|
||||
channels = append(channels, middleware.SessionChannelTerminal)
|
||||
case authn.PermAi:
|
||||
channels = append(channels, middleware.SessionChannelAi)
|
||||
}
|
||||
}
|
||||
return channels
|
||||
}
|
||||
|
||||
func parsePermissions(raw []string) ([]authn.Permission, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
permissions := make([]authn.Permission, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
permissions = append(permissions, authn.Permission(item))
|
||||
}
|
||||
if err := authn.ValidatePermissions(permissions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return permissions, nil
|
||||
}
|
||||
|
||||
func permissionStrings(permissions []authn.Permission) []string {
|
||||
strings := make([]string, 0, len(permissions))
|
||||
for _, permission := range permissions {
|
||||
strings = append(strings, string(permission))
|
||||
}
|
||||
return strings
|
||||
}
|
||||
|
||||
func decodePassword(encrypted string) (string, error) {
|
||||
password, err := utils.DecodeDecrypt(encrypted)
|
||||
if err != nil || password == "" {
|
||||
|
||||
53
server/service/auth/users_test.go
Normal file
53
server/service/auth/users_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
)
|
||||
|
||||
func TestRotateAPIKeyOnlyWhenLostAndRequested(t *testing.T) {
|
||||
calls := 0
|
||||
failing := false
|
||||
original := APIKeyRotator
|
||||
APIKeyRotator = func() error {
|
||||
calls++
|
||||
if failing {
|
||||
return errors.New("disk full")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defer func() { APIKeyRotator = original }()
|
||||
|
||||
withMcp := []authn.Permission{authn.PermConsoleView, authn.PermMcp}
|
||||
without := []authn.Permission{authn.PermConsoleView}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
previous, current []authn.Permission
|
||||
requested bool
|
||||
wantRotated bool
|
||||
}{
|
||||
{"kept grant", withMcp, withMcp, true, false},
|
||||
{"never held", without, without, true, false},
|
||||
{"lost, kept by request", withMcp, without, false, false},
|
||||
{"lost, rotation requested", withMcp, without, true, true},
|
||||
{"deleted, rotation requested", withMcp, nil, true, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
calls = 0
|
||||
rotated, err := rotateAPIKeyIfLost(tc.previous, tc.current, tc.requested)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.name, err)
|
||||
}
|
||||
if rotated != tc.wantRotated || calls != map[bool]int{false: 0, true: 1}[tc.wantRotated] {
|
||||
t.Fatalf("%s: rotated=%v calls=%d", tc.name, rotated, calls)
|
||||
}
|
||||
}
|
||||
|
||||
failing = true
|
||||
if rotated, err := rotateAPIKeyIfLost(withMcp, without, true); rotated || !errors.Is(err, errAPIKeyNotRotated) {
|
||||
t.Fatalf("failed rotation: rotated=%v err=%v", rotated, err)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package download
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/service/storage"
|
||||
"NanoKVM-Server/service/storage/diskfs"
|
||||
"NanoKVM-Server/utils"
|
||||
"bytes"
|
||||
"context"
|
||||
@@ -14,14 +16,13 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type downloadStatus string
|
||||
@@ -33,30 +34,92 @@ const (
|
||||
downloadStatusSuccess downloadStatus = "success"
|
||||
downloadStatusFailed downloadStatus = "failed"
|
||||
downloadStatusChecksumFailed downloadStatus = "checksum_failed"
|
||||
|
||||
imageDirectory = "/data"
|
||||
|
||||
// transferTempPrefix marks the partial files of the uploads and downloads
|
||||
// this service writes. The file manager hides them and refuses to delete
|
||||
// them while a transfer runs (diskfs.IsTransferTemp), and a restart sweeps
|
||||
// whatever a transfer left behind.
|
||||
transferTempPrefix = diskfs.DownloadTempPrefix
|
||||
|
||||
// Keep /data from filling up completely: the exFAT metadata and the USB
|
||||
// mass-storage export still need room while the image is written.
|
||||
imageFreeReserve = 128 << 20
|
||||
)
|
||||
|
||||
var (
|
||||
errDownloadInProgress = errors.New("download in progress")
|
||||
errSHA256Mismatch = errors.New("sha256 mismatch")
|
||||
validISOFilename = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
||||
)
|
||||
|
||||
// maxUploadBytes caps a single upload even when the client lies about its
|
||||
// length, so a runaway request cannot fill the card before the free space
|
||||
// check has had its say.
|
||||
const maxUploadBytes = 8 << 30
|
||||
|
||||
type Service struct {
|
||||
downloadMutex sync.Mutex
|
||||
downloadCancel context.CancelFunc
|
||||
downloadDone chan struct{}
|
||||
downloadStatus downloadStatus
|
||||
downloadFile string
|
||||
downloadPercentage string
|
||||
downloadMutex sync.Mutex
|
||||
downloadCancel context.CancelFunc
|
||||
downloadDone chan struct{}
|
||||
downloadStatus downloadStatus
|
||||
downloadFile string
|
||||
downloadLoaded int64
|
||||
downloadTotal int64
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
// A running transfer cannot survive a server restart, so any remaining lock
|
||||
// file is stale at this point.
|
||||
_ = os.Remove(transferSentinelPath)
|
||||
go removeStaleTransfers()
|
||||
|
||||
return &Service{downloadStatus: downloadStatusIdle}
|
||||
}
|
||||
|
||||
// removeStaleTransfers deletes the temporary files of uploads and downloads
|
||||
// that a restart interrupted. They are hidden and can be hundreds of
|
||||
// megabytes, and hidden from the file manager, so leaving them behind would
|
||||
// quietly fill the card. A file upload into the shared folder
|
||||
// writes its temporary next to its target, so the whole tree is walked, not
|
||||
// just the root.
|
||||
//
|
||||
// The walk covers the whole data partition, so it runs in the background, and
|
||||
// a transfer may start while it does: only names one of our transfers would
|
||||
// generate are collected, and each is removed only while no transfer runs. A
|
||||
// transfer started after the walk writes a fresh name the walk never saw.
|
||||
// While the target machine owns the partition it is read-only here, and the
|
||||
// sweep waits for the next start.
|
||||
func removeStaleTransfers() {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs(imageDirectory, &stat); err != nil || stat.Flags&unix.ST_RDONLY != 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var stale []string
|
||||
_ = filepath.Walk(imageDirectory, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if diskfs.IsTransferTemp(info.Name()) {
|
||||
stale = append(stale, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
for _, path := range stale {
|
||||
if storage.TransferInProgress() {
|
||||
log.Infof("leave stale transfer %s: a transfer is running", path)
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
log.Errorf("remove stale transfer %s failed: %s", path, err)
|
||||
} else {
|
||||
log.Infof("removed stale transfer %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) CancelDownloadImage(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
@@ -108,7 +171,8 @@ func (s *Service) beginDownload(file string, cancel context.CancelFunc) (chan st
|
||||
s.downloadDone = done
|
||||
s.downloadStatus = downloadStatusInProgress
|
||||
s.downloadFile = file
|
||||
s.downloadPercentage = ""
|
||||
s.downloadLoaded = 0
|
||||
s.downloadTotal = 0
|
||||
|
||||
return done, nil
|
||||
}
|
||||
@@ -122,12 +186,13 @@ func (s *Service) setDownloadFile(done chan struct{}, file string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) setDownloadProgress(done chan struct{}, percentage string) {
|
||||
func (s *Service) setDownloadProgress(done chan struct{}, loaded, total int64) {
|
||||
s.downloadMutex.Lock()
|
||||
defer s.downloadMutex.Unlock()
|
||||
|
||||
if s.downloadDone == done {
|
||||
s.downloadPercentage = percentage
|
||||
s.downloadLoaded = loaded
|
||||
s.downloadTotal = total
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +207,8 @@ func (s *Service) finishDownload(done chan struct{}, status downloadStatus) {
|
||||
s.downloadDone = nil
|
||||
s.downloadStatus = status
|
||||
s.downloadFile = ""
|
||||
s.downloadPercentage = ""
|
||||
s.downloadLoaded = 0
|
||||
s.downloadTotal = 0
|
||||
_ = os.Remove(transferSentinelPath)
|
||||
s.downloadMutex.Unlock()
|
||||
|
||||
@@ -164,24 +230,50 @@ func (s *Service) ImageEnabled(c *gin.Context) {
|
||||
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{Enabled: true})
|
||||
}
|
||||
|
||||
func isISO9660(path string) (bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// ISO-9660 magic "CD001" at offset 32769.
|
||||
if _, err = f.Seek(0x8001, io.SeekStart); err != nil {
|
||||
return false, err
|
||||
// ensureImageSpace rejects an upload that cannot fit into dir, keeping a small
|
||||
// reserve for filesystem metadata and for the USB mass-storage export.
|
||||
func ensureImageSpace(dir string, contentLength int64) error {
|
||||
if contentLength <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
buf := make([]byte, 5)
|
||||
if _, err = io.ReadFull(f, buf); err != nil {
|
||||
return false, err
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs(dir, &stat); err != nil {
|
||||
return fmt.Errorf("cannot read free space of %s: %w", dir, err)
|
||||
}
|
||||
|
||||
return string(buf) == "CD001", nil
|
||||
available := uint64(stat.Bavail) * uint64(stat.Bsize)
|
||||
payload := uint64(contentLength)
|
||||
if payload > available || available-payload < imageFreeReserve {
|
||||
return fmt.Errorf(
|
||||
"not enough space in %s: need %d MiB including reserve, %d MiB available",
|
||||
dir,
|
||||
(payload+imageFreeReserve)>>20,
|
||||
available>>20,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// imageSpaceLimit is how many bytes a download may write into dir: its
|
||||
// announced length, which ensureImageSpace has already checked, or else what
|
||||
// fits while keeping the reserve.
|
||||
func imageSpaceLimit(dir string, contentLength int64) (int64, error) {
|
||||
if contentLength >= 0 {
|
||||
return contentLength, nil
|
||||
}
|
||||
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs(dir, &stat); err != nil {
|
||||
return 0, fmt.Errorf("cannot read free space of %s: %w", dir, err)
|
||||
}
|
||||
|
||||
available := uint64(stat.Bavail) * uint64(stat.Bsize)
|
||||
if available <= imageFreeReserve {
|
||||
return 0, fmt.Errorf("not enough space in %s: %d MiB available", dir, available>>20)
|
||||
}
|
||||
return int64(available - imageFreeReserve), nil
|
||||
}
|
||||
|
||||
func (s *Service) StatusImage(c *gin.Context) {
|
||||
@@ -190,13 +282,16 @@ func (s *Service) StatusImage(c *gin.Context) {
|
||||
s.downloadMutex.Lock()
|
||||
status := s.downloadStatus
|
||||
file := s.downloadFile
|
||||
percentage := s.downloadPercentage
|
||||
loaded := s.downloadLoaded
|
||||
total := s.downloadTotal
|
||||
s.downloadMutex.Unlock()
|
||||
|
||||
rsp.OkRspWithData(c, &proto.StatusImageRsp{
|
||||
Status: string(status),
|
||||
File: file,
|
||||
Percentage: percentage,
|
||||
Percentage: utils.FormatPercentage(loaded, total),
|
||||
Loaded: loaded,
|
||||
Total: total,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -204,12 +299,31 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
log.Debug("DownloadImageFile")
|
||||
|
||||
// Everything below lands on the data partition, which the target machine
|
||||
// owns whenever the emulated disk is attached to it. Hold that ownership
|
||||
// decision for the whole transfer: a mount must wait instead of remounting
|
||||
// /data underneath an upload that is already streaming.
|
||||
releaseDataPartitionWrite, err := storage.BeginDataPartitionWrite()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
defer releaseDataPartitionWrite()
|
||||
|
||||
expectedSHA256, err := parseSHA256(c.GetHeader("X-SHA256-Sum"))
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Reject an upload that cannot fit before any of it is received.
|
||||
if err := ensureImageSpace(imageDirectory, c.Request.ContentLength); err != nil {
|
||||
log.Warnf("reject image upload: %s", err)
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
done, err := s.beginDownload("", nil)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
@@ -220,6 +334,11 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
s.finishDownload(done, finalStatus)
|
||||
}()
|
||||
|
||||
// The whole body is written to the data partition, so cap the request even
|
||||
// when the client lies about its length. This has to happen before the
|
||||
// multipart reader is created: it keeps reading from the body it was handed.
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadBytes)
|
||||
|
||||
reader, err := c.Request.MultipartReader()
|
||||
if err != nil {
|
||||
log.Error("invalid multipart data")
|
||||
@@ -243,17 +362,19 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
}
|
||||
|
||||
filename := part.FileName()
|
||||
if err := validateISOFilename(filename); err != nil {
|
||||
if err := validateImageFilename(filename); err != nil {
|
||||
_ = part.Close()
|
||||
log.Warnf("reject uploaded image %q: %s", filename, err)
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
s.setDownloadFile(done, filename)
|
||||
|
||||
out, err := os.CreateTemp("/data", ".nanokvm-upload-*")
|
||||
// The rejection paths below return without draining the request body: the
|
||||
// client stops uploading as soon as the response arrives.
|
||||
out, err := os.CreateTemp(imageDirectory, transferTempPrefix+"upload-*")
|
||||
if err != nil {
|
||||
_ = part.Close()
|
||||
log.Error("cannot create temporary file")
|
||||
log.Errorf("cannot create temporary file in %s: %s", imageDirectory, err)
|
||||
rsp.ErrRsp(c, -1, "cannot create temporary file")
|
||||
return
|
||||
}
|
||||
@@ -261,15 +382,18 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
hasher := sha256.New()
|
||||
lw := newLoggingWriter(io.MultiWriter(out, hasher), c.Request.ContentLength, func(percentage string) {
|
||||
s.setDownloadProgress(done, percentage)
|
||||
lw := utils.NewProgressWriter(io.MultiWriter(out, hasher), c.Request.ContentLength, func(loaded, total int64) {
|
||||
s.setDownloadProgress(done, loaded, total)
|
||||
})
|
||||
// The name already said what this has to be: .iso or .img. The bytes
|
||||
// are taken as they come; a file that turns out not to be an image
|
||||
// fails when it is mounted, not while it is stored.
|
||||
_, copyErr := io.Copy(lw, part)
|
||||
lw.stopTicker()
|
||||
lw.Stop()
|
||||
partCloseErr := part.Close()
|
||||
outCloseErr := out.Close()
|
||||
if copyErr != nil || partCloseErr != nil || outCloseErr != nil {
|
||||
log.Error("write failed")
|
||||
log.Errorf("write uploaded image %q failed: %s", filename, errors.Join(copyErr, partCloseErr, outCloseErr))
|
||||
rsp.ErrRsp(c, -1, "write failed")
|
||||
return
|
||||
}
|
||||
@@ -280,15 +404,9 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
valid, err := isISO9660(tempPath)
|
||||
if err != nil || !valid {
|
||||
rsp.ErrRsp(c, -1, "file is not a valid ISO image")
|
||||
return
|
||||
}
|
||||
|
||||
outPath := filepath.Join("/data", filename)
|
||||
if err := os.Rename(tempPath, outPath); err != nil {
|
||||
rsp.ErrRsp(c, -1, "cannot install uploaded image")
|
||||
outPath := filepath.Join(imageDirectory, filename)
|
||||
if err := storage.InstallImage(tempPath, outPath); err != nil {
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -304,20 +422,15 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
rsp.ErrRsp(c, -1, "file is required")
|
||||
}
|
||||
|
||||
func validateISOFilename(filename string) error {
|
||||
if filename == "" {
|
||||
return errors.New("no filename")
|
||||
// validateImageFilename accepts what the emulated disk can expose: ISO images
|
||||
// and raw disk images. The name never reaches the filesystem unchecked, and
|
||||
// the rules are the ones a FAT volume can hold, so a medium named the same way
|
||||
// is reachable from the file manager too.
|
||||
func validateImageFilename(filename string) error {
|
||||
if !utils.IsMediaFile(filename) {
|
||||
return errors.New("only .iso and .img files allowed")
|
||||
}
|
||||
if filepath.Base(filename) != filename || strings.Contains(filename, "..") {
|
||||
return errors.New("invalid filename")
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(filename), ".iso") {
|
||||
return errors.New("only .iso files allowed")
|
||||
}
|
||||
if !validISOFilename.MatchString(filename) {
|
||||
return errors.New("invalid filename")
|
||||
}
|
||||
return nil
|
||||
return utils.ValidateFileName(filename)
|
||||
}
|
||||
|
||||
func (s *Service) DownloadImage(c *gin.Context) {
|
||||
@@ -348,19 +461,30 @@ func (s *Service) DownloadImage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// The download runs after this handler returns, and its temporary file is
|
||||
// on the same partition the raw medium exposes. Keep the partition reserved
|
||||
// until the goroutine has installed or removed that file.
|
||||
releaseDataPartitionWrite, err := storage.BeginDataPartitionWrite()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done, err := s.beginDownload(req.File, cancel)
|
||||
if err != nil {
|
||||
releaseDataPartitionWrite()
|
||||
cancel()
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer releaseDataPartitionWrite()
|
||||
defer cancel()
|
||||
|
||||
if err := s.downloadRemoteImage(ctx, req.File, expectedSHA256, filename, func(percentage string) {
|
||||
s.setDownloadProgress(done, percentage)
|
||||
if err := s.downloadRemoteImage(ctx, req.File, expectedSHA256, filename, func(loaded, total int64) {
|
||||
s.setDownloadProgress(done, loaded, total)
|
||||
}); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
log.Debug("Image download canceled")
|
||||
@@ -405,7 +529,7 @@ func (s *Service) downloadRemoteImage(
|
||||
rawURL string,
|
||||
expectedSHA256 []byte,
|
||||
filename string,
|
||||
onProgress func(string),
|
||||
onProgress func(loaded, total int64),
|
||||
) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
@@ -422,7 +546,22 @@ func (s *Service) downloadRemoteImage(
|
||||
return fmt.Errorf("download request returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp("/data", ".nanokvm-download-*")
|
||||
// The response headers say how large the image is: refuse it now instead
|
||||
// of filling the partition and failing at the end. An unknown length
|
||||
// (chunked responses) skips the check and is caught by the write itself.
|
||||
if err := ensureImageSpace(imageDirectory, resp.ContentLength); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A response without a length is capped by the free space instead, so a
|
||||
// server that never stops sending cannot fill the partition. One byte more
|
||||
// than the cap is read to tell "exactly fits" from "does not fit".
|
||||
limit, err := imageSpaceLimit(imageDirectory, resp.ContentLength)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp(imageDirectory, transferTempPrefix+"download-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary image failed: %w", err)
|
||||
}
|
||||
@@ -430,9 +569,9 @@ func (s *Service) downloadRemoteImage(
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
hasher := sha256.New()
|
||||
lw := newLoggingWriter(io.MultiWriter(tempFile, hasher), resp.ContentLength, onProgress)
|
||||
_, copyErr := io.Copy(lw, resp.Body)
|
||||
lw.stopTicker()
|
||||
lw := utils.NewProgressWriter(io.MultiWriter(tempFile, hasher), resp.ContentLength, onProgress)
|
||||
written, copyErr := io.Copy(lw, io.LimitReader(resp.Body, limit+1))
|
||||
lw.Stop()
|
||||
closeErr := tempFile.Close()
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
@@ -443,6 +582,15 @@ func (s *Service) downloadRemoteImage(
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close downloaded image failed: %w", closeErr)
|
||||
}
|
||||
if written > limit {
|
||||
return fmt.Errorf("not enough space in %s for the downloaded image", imageDirectory)
|
||||
}
|
||||
// net/http already fails a body shorter than its Content-Length and cuts
|
||||
// a longer one off, but an image that is not the size the server
|
||||
// announced is not one to install either way.
|
||||
if resp.ContentLength >= 0 && written != resp.ContentLength {
|
||||
return fmt.Errorf("downloaded image is %d bytes, the server announced %d", written, resp.ContentLength)
|
||||
}
|
||||
|
||||
if expectedSHA256 != nil && !bytes.Equal(hasher.Sum(nil), expectedSHA256) {
|
||||
return errSHA256Mismatch
|
||||
@@ -451,70 +599,10 @@ func (s *Service) downloadRemoteImage(
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
destPath := filepath.Join("/data", filename)
|
||||
if err := os.Rename(tempPath, destPath); err != nil {
|
||||
return fmt.Errorf("install downloaded image failed: %w", err)
|
||||
destPath := filepath.Join(imageDirectory, filename)
|
||||
if err := storage.InstallImage(tempPath, destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type loggingWriter struct {
|
||||
writer io.Writer
|
||||
total atomic.Int64
|
||||
totalSize int64
|
||||
ticker *time.Ticker
|
||||
done chan struct{}
|
||||
stopOnce sync.Once
|
||||
onProgress func(string)
|
||||
}
|
||||
|
||||
func newLoggingWriter(writer io.Writer, totalSize int64, onProgress func(string)) *loggingWriter {
|
||||
lw := &loggingWriter{
|
||||
writer: writer,
|
||||
totalSize: totalSize,
|
||||
onProgress: onProgress,
|
||||
}
|
||||
lw.startTicker()
|
||||
return lw
|
||||
}
|
||||
|
||||
func (lw *loggingWriter) startTicker() {
|
||||
lw.ticker = time.NewTicker(2500 * time.Millisecond)
|
||||
lw.done = make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-lw.done:
|
||||
return
|
||||
case <-lw.ticker.C:
|
||||
lw.updateProgress()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (lw *loggingWriter) stopTicker() {
|
||||
if lw == nil {
|
||||
return
|
||||
}
|
||||
lw.stopOnce.Do(func() {
|
||||
lw.ticker.Stop()
|
||||
close(lw.done)
|
||||
})
|
||||
}
|
||||
|
||||
func (lw *loggingWriter) updateProgress() {
|
||||
if lw.totalSize <= 0 || lw.onProgress == nil {
|
||||
return
|
||||
}
|
||||
|
||||
percentage := float64(lw.total.Load()) / float64(lw.totalSize) * 100
|
||||
lw.onProgress(fmt.Sprintf("%.2f%%", percentage))
|
||||
}
|
||||
|
||||
func (lw *loggingWriter) Write(p []byte) (int, error) {
|
||||
n, err := lw.writer.Write(p)
|
||||
lw.total.Add(int64(n))
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -146,6 +146,18 @@ func (h *Hid) OpenNoLockWithRetry(timeout, delay time.Duration) error {
|
||||
return openNoLockWithRetry(h.OpenNoLock, timeout, delay)
|
||||
}
|
||||
|
||||
// ReopenAfterRebind reopens the HID devices after the USB gadget was
|
||||
// re-enumerated. The hidg nodes take a moment to come back, so the reopen is
|
||||
// retried. Devices built without HID (/boot/disable_hid) have nothing to open,
|
||||
// and a failure here must not turn a successful gadget operation into an
|
||||
// error, so it is only logged.
|
||||
func ReopenAfterRebind() {
|
||||
h := GetHid()
|
||||
if err := h.OpenNoLockWithRetry(hidReopenTimeout, hidReopenRetryDelay); err != nil {
|
||||
log.Errorf("reopen HID devices after USB rebind failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func openNoLockWithRetry(open func() error, timeout, delay time.Duration) error {
|
||||
if timeout <= 0 {
|
||||
return open()
|
||||
|
||||
@@ -8,9 +8,5 @@ import (
|
||||
)
|
||||
|
||||
func New() mcpservice.Snapshotter {
|
||||
return mcpcapture.NewWithCaptureLease(common.GetKvmVision(), func() (uint16, uint16) {
|
||||
screen := common.GetScreen()
|
||||
common.CheckScreen()
|
||||
return screen.Width, screen.Height
|
||||
}, vm.AcquireHdmiCaptureLeaseForRead)
|
||||
return mcpcapture.NewWithCaptureLease(common.GetKvmVision(), common.InputSize, vm.AcquireHdmiCaptureLeaseForRead)
|
||||
}
|
||||
|
||||
@@ -135,6 +135,14 @@ func ensureAPIKey(cfg Config) (Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// RotateAPIKey replaces the device-wide API key. The key is shared by every
|
||||
// account that may read it, so once one of them loses the mcp grant the key it
|
||||
// may have copied has to stop working.
|
||||
func RotateAPIKey() error {
|
||||
_, err := updateConfig(regenerateAPIKey)
|
||||
return err
|
||||
}
|
||||
|
||||
func regenerateAPIKey(cfg Config) (Config, error) {
|
||||
key, err := generateAPIKey()
|
||||
if err != nil {
|
||||
|
||||
@@ -60,7 +60,7 @@ func (s *Service) Screenshot(c *gin.Context) {
|
||||
func (s *Service) captureScreenshot(ctx context.Context, query ScreenshotQuery) ([]byte, ScreenshotMeta, *PicoclawError) {
|
||||
width, height, quality := resolveScreenshotRequest(query)
|
||||
|
||||
screen := common.GetScreen()
|
||||
sourceWidth, sourceHeight := common.InputSize()
|
||||
common.CheckScreen()
|
||||
releaseLease, claimFresh, leaseErr := s.acquireCaptureLease(ctx)
|
||||
if leaseErr != nil {
|
||||
@@ -94,8 +94,8 @@ func (s *Service) captureScreenshot(ctx context.Context, query ScreenshotQuery)
|
||||
continue
|
||||
}
|
||||
return data, ScreenshotMeta{
|
||||
SourceWidth: screen.Width,
|
||||
SourceHeight: screen.Height,
|
||||
SourceWidth: sourceWidth,
|
||||
SourceHeight: sourceHeight,
|
||||
CaptureWidth: width,
|
||||
CaptureHeight: height,
|
||||
Format: "jpeg",
|
||||
@@ -108,8 +108,7 @@ func (s *Service) captureScreenshot(ctx context.Context, query ScreenshotQuery)
|
||||
|
||||
func resolveScreenshotRequest(query ScreenshotQuery) (uint16, uint16, uint16) {
|
||||
screen := common.GetScreen()
|
||||
width := screen.Width
|
||||
height := screen.Height
|
||||
width, height := common.InputSize()
|
||||
quality := screen.Quality
|
||||
|
||||
if query.Format == "base64" {
|
||||
|
||||
294
server/service/storage/cache.go
Normal file
294
server/service/storage/cache.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"NanoKVM-Server/service/storage/diskfs"
|
||||
)
|
||||
|
||||
// The shared files medium is the data partition block device handed to the
|
||||
// target machine while this device keeps it mounted: two writers on one
|
||||
// filesystem. The target machine's writes arrive through the emulated USB disk
|
||||
// and land in this device's page cache for that block device - the very cache
|
||||
// the mounted exFAT reads its directory data from, so the file data on this
|
||||
// side already is what the target machine wrote. What does not follow is the
|
||||
// metadata this side cached: a file the target grew keeps the size it had when
|
||||
// it was first read, and a directory that grew keeps the cluster chain it was
|
||||
// first walked with. Dropping the dentry and inode caches makes a listing see
|
||||
// the target machine's version. The page cache is deliberately left alone: it
|
||||
// holds that version already, and dropping it would only add card reads.
|
||||
//
|
||||
// Nothing tells this side that those block level writes happened. They bypass
|
||||
// the VFS, so inotify and fanotify stay silent, and the partition's write
|
||||
// counter in /proc/diskstats only moves once this side writes its own dirty
|
||||
// pages back - which can be tens of seconds after the target wrote, or when
|
||||
// the target itself asks the disk to flush. The watcher therefore refreshes on
|
||||
// a timer while the target machine owns the partition; the interval only
|
||||
// bounds how long the web UI can lag behind the target's own writeback.
|
||||
const (
|
||||
// partitionWatchInterval is how often the shared files view is refreshed
|
||||
// while the target machine owns the partition. Reading one directory is
|
||||
// cheap, so the interval is chosen to keep a listing from looking stuck,
|
||||
// not to save work.
|
||||
partitionWatchInterval = time.Second
|
||||
// partitionWriteQuiet is how long a change keeps counting as a transfer in
|
||||
// progress. The target machine flushes in bursts, and its own writeback can
|
||||
// hold the final size update for tens of seconds (Linux flushes dirty
|
||||
// metadata only after dirty_expire_centisecs), so a short quiet spell is
|
||||
// not the end of a copy.
|
||||
partitionWriteQuiet = 45 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
partitionCacheMu sync.Mutex
|
||||
partitionLastChange time.Time
|
||||
// partitionWatchPaths is every directory the file browser is showing: the
|
||||
// one it listed plus the folders it has expanded. A change inside a
|
||||
// subdirectory does not move its parent's signature, so all of them have
|
||||
// to be watched, not just the last one listed.
|
||||
partitionWatchPaths []string
|
||||
partitionListeners []func()
|
||||
partitionWatchOnce sync.Once
|
||||
)
|
||||
|
||||
// startPartitionWatcher starts the periodic refresh of the shared files view.
|
||||
func startPartitionWatcher() {
|
||||
partitionWatchOnce.Do(func() {
|
||||
go watchPartitionChanges()
|
||||
})
|
||||
}
|
||||
|
||||
func watchPartitionChanges() {
|
||||
var lastSignature string
|
||||
var lastWatch []string
|
||||
// While a transfer is in progress the UI labels zero-length entries as
|
||||
// uploads. The quiet window ending is not a change on disk, so it is the
|
||||
// one transition the signature cannot report: without its own
|
||||
// notification, a finished copy keeps looking like an upload that never
|
||||
// completes.
|
||||
reportedWriting := false
|
||||
|
||||
for {
|
||||
time.Sleep(partitionWatchInterval)
|
||||
|
||||
// Only the target machine's writes bypass this side's caches; while
|
||||
// this device is the only writer, the filesystem's own caches are
|
||||
// current and dropping them would be wasted work.
|
||||
if !IsAttached(partitionDevice) {
|
||||
lastSignature = ""
|
||||
lastWatch = nil
|
||||
reportedWriting = false
|
||||
continue
|
||||
}
|
||||
|
||||
invalidatePartitionCache()
|
||||
|
||||
watch := watchedDirectories()
|
||||
signature := partitionSignature(watch)
|
||||
|
||||
// The browser changing which folders it shows is not a write: the
|
||||
// signature is not comparable with the previous one, and treating the
|
||||
// difference as a transfer keeps an empty file labelled as an upload
|
||||
// forever. Re-baseline instead, without touching the quiet window.
|
||||
if !sameDirectories(watch, lastWatch) {
|
||||
lastWatch = watch
|
||||
lastSignature = signature
|
||||
continue
|
||||
}
|
||||
|
||||
// The signature is taken after the drop, so it compares what a listing
|
||||
// would show now. It is what tells a transfer in progress - the sizes
|
||||
// grow - from an idle medium, and it is also what keeps an idle shared
|
||||
// folder from making the web UI re-read it every second.
|
||||
if signature != lastSignature {
|
||||
lastSignature = signature
|
||||
|
||||
partitionCacheMu.Lock()
|
||||
partitionLastChange = time.Now()
|
||||
partitionCacheMu.Unlock()
|
||||
|
||||
reportedWriting = true
|
||||
notifyPartitionListeners()
|
||||
continue
|
||||
}
|
||||
|
||||
if reportedWriting && !partitionWriting() {
|
||||
reportedWriting = false
|
||||
notifyPartitionListeners()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// watchedDirectories returns the directories to read, sorted so that a set the
|
||||
// browser sends in another order is still the same set.
|
||||
func watchedDirectories() []string {
|
||||
partitionCacheMu.Lock()
|
||||
paths := append([]string{}, partitionWatchPaths...)
|
||||
partitionCacheMu.Unlock()
|
||||
|
||||
sort.Strings(paths)
|
||||
return paths
|
||||
}
|
||||
|
||||
func sameDirectories(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SetPartitionWatchPaths tells the watcher which directories of the shared
|
||||
// folder the web UI is showing: the one being listed and every folder it has
|
||||
// expanded. A change inside a subdirectory does not move the folder's own
|
||||
// signature, so each open folder is watched on its own. An empty list stops
|
||||
// watching subdirectories.
|
||||
func SetPartitionWatchPaths(paths []string) {
|
||||
partitionCacheMu.Lock()
|
||||
partitionWatchPaths = append([]string{}, paths...)
|
||||
partitionCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// SubscribePartitionChanges registers fn for "the shared files view was
|
||||
// refreshed" notifications: once per refresh while the target machine owns the
|
||||
// partition. fn runs on the watcher goroutine.
|
||||
func SubscribePartitionChanges(fn func()) {
|
||||
partitionCacheMu.Lock()
|
||||
defer partitionCacheMu.Unlock()
|
||||
|
||||
partitionListeners = append(partitionListeners, fn)
|
||||
}
|
||||
|
||||
func notifyPartitionListeners() {
|
||||
partitionCacheMu.Lock()
|
||||
listeners := append([]func(){}, partitionListeners...)
|
||||
partitionCacheMu.Unlock()
|
||||
|
||||
for _, fn := range listeners {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
// partitionWriting reports whether the shared files changed within the quiet
|
||||
// window. A zero length file listed in that state is a transfer in progress,
|
||||
// not an empty file.
|
||||
func partitionWriting() bool {
|
||||
// Only the target machine's writes arrive without this side knowing, so a
|
||||
// hint left over from the last time it owned the partition would mislead a
|
||||
// file browser that is reading the medium itself now.
|
||||
if !IsAttached(partitionDevice) {
|
||||
return false
|
||||
}
|
||||
|
||||
partitionCacheMu.Lock()
|
||||
defer partitionCacheMu.Unlock()
|
||||
|
||||
return !partitionLastChange.IsZero() && time.Since(partitionLastChange) < partitionWriteQuiet
|
||||
}
|
||||
|
||||
// invalidatePartitionCache drops the stale metadata caches. It is the entry
|
||||
// point both for the watcher and for a listing, so a request never has to wait
|
||||
// for the next tick.
|
||||
func invalidatePartitionCache() {
|
||||
if !IsAttached(partitionDevice) {
|
||||
return
|
||||
}
|
||||
|
||||
dropPartitionCaches()
|
||||
}
|
||||
|
||||
// partitionSignature summarizes what a listing would show: the shared folder
|
||||
// itself, which is where files are exchanged, and every directory the file
|
||||
// browser has open. Names, sizes and timestamps are all compared, because a
|
||||
// copy changes the size of an entry it already added.
|
||||
func partitionSignature(paths []string) string {
|
||||
var signature strings.Builder
|
||||
signature.WriteString(directorySignature(mediaDirectory))
|
||||
|
||||
seen := map[string]bool{"/": true}
|
||||
for _, browsed := range paths {
|
||||
if browsed == "" || seen[browsed] {
|
||||
continue
|
||||
}
|
||||
seen[browsed] = true
|
||||
|
||||
dir, err := diskfs.ResolveInside(mediaDirectory, browsed)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
signature.WriteString(directorySignature(dir))
|
||||
}
|
||||
|
||||
return signature.String()
|
||||
}
|
||||
|
||||
func directorySignature(dir string) string {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var signature strings.Builder
|
||||
for _, entry := range entries {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue // vanished between the directory read and the stat
|
||||
}
|
||||
fmt.Fprintf(&signature, "%s|%d|%d|%t\n", entry.Name(), info.Size(), info.ModTime().Unix(), entry.IsDir())
|
||||
}
|
||||
|
||||
return signature.String()
|
||||
}
|
||||
|
||||
// drop_caches values. The sysctl only accepts 1 to 4 (the 5.10 table caps it
|
||||
// with extra2 = 4), so a combined value such as 6 is refused with EINVAL and
|
||||
// drops nothing. Bit 4 is written once on its own instead: it sets a sticky
|
||||
// flag in fs/drop_caches.c that keeps every later drop out of the kernel log,
|
||||
// which is how a share lasting a day used to fill the log on the data
|
||||
// partition.
|
||||
const (
|
||||
dropCachesQuiet = "4" // stop logging drops from now on
|
||||
dropCachesDentry = "2" // dentries and inodes
|
||||
dropCachesAllData = "3" // page cache, dentries and inodes
|
||||
)
|
||||
|
||||
var dropCachesQuietOnce sync.Once
|
||||
|
||||
func dropCaches(value string) {
|
||||
dropCachesQuietOnce.Do(func() {
|
||||
writeDropCaches(dropCachesQuiet)
|
||||
})
|
||||
writeDropCaches(value)
|
||||
}
|
||||
|
||||
func writeDropCaches(value string) {
|
||||
if err := os.WriteFile("/proc/sys/vm/drop_caches", []byte(value), 0); err != nil {
|
||||
log.Warnf("drop stale caches (%s) failed: %s", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
// dropPartitionCaches drops the dentries and inodes, which is what makes a
|
||||
// listing see the target machine's entries and sizes. Dirty pages survive a
|
||||
// drop, so gadget writes still in flight are not lost.
|
||||
func dropPartitionCaches() {
|
||||
dropCaches(dropCachesDentry)
|
||||
}
|
||||
|
||||
// dropAllPartitionCaches throws away everything this side cached of the data
|
||||
// partition, page cache included: it runs when the partition stops being
|
||||
// served to the target machine, where a stale page written back would land on
|
||||
// top of what the target machine just wrote.
|
||||
func dropAllPartitionCaches() {
|
||||
dropCaches(dropCachesAllData)
|
||||
}
|
||||
120
server/service/storage/data.go
Normal file
120
server/service/storage/data.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// Only one side may write the data partition at a time. The emulated USB disk
|
||||
// shares that partition with the target machine as a raw, writable disk, and
|
||||
// while it does the target machine is its only writer, so /data is remounted
|
||||
// read-only here: remounting keeps every writer on this side away from the
|
||||
// filesystem, not just the file manager that checks openMedium first.
|
||||
// Mounting an image read-only hands the partition back to this side, and so
|
||||
// does releasing the medium. Every read-only to writable transition drops the
|
||||
// cache built while the target owned the partition before writers are allowed
|
||||
// back in; callers must not remount it writable first and clean up afterwards.
|
||||
func SetDataWritable(writable bool) error {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs(mediaDirectory, &stat); err != nil {
|
||||
return fmt.Errorf("stat %s: %w", mediaDirectory, err)
|
||||
}
|
||||
|
||||
if (stat.Flags&unix.ST_RDONLY == 0) == writable {
|
||||
return nil
|
||||
}
|
||||
|
||||
option := "rw"
|
||||
if !writable {
|
||||
option = "ro"
|
||||
} else {
|
||||
// The target may have changed both file data and allocation metadata
|
||||
// while it owned the block device. Invalidate that view before the
|
||||
// filesystem becomes writable, on every path that hands it back.
|
||||
// A remount does not recount exFAT's used clusters (counted only at
|
||||
// mount), so free space stays stale and freeing what the target wrote
|
||||
// can underflow that count until /data is mounted afresh.
|
||||
dropAllPartitionCaches()
|
||||
}
|
||||
|
||||
if err := exec.Command("mount", "-o", "remount,"+option, mediaDirectory).Run(); err != nil {
|
||||
return fmt.Errorf("remount %s %s: %w", mediaDirectory, option, err)
|
||||
}
|
||||
|
||||
// Never leave a writable /data behind for a target machine that is writing
|
||||
// the same filesystem.
|
||||
var check unix.Statfs_t
|
||||
if err := unix.Statfs(mediaDirectory, &check); err != nil {
|
||||
return fmt.Errorf("stat %s: %w", mediaDirectory, err)
|
||||
}
|
||||
if (check.Flags&unix.ST_RDONLY == 0) != writable {
|
||||
return errors.New(mediaDirectory + " mount did not change")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DataWritable reports whether this side may write the data partition now.
|
||||
func DataWritable() bool {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs(mediaDirectory, &stat); err != nil {
|
||||
return false
|
||||
}
|
||||
return stat.Flags&unix.ST_RDONLY == 0
|
||||
}
|
||||
|
||||
// healFailureInterval throttles the warning a heal that keeps failing would
|
||||
// otherwise write: the state is polled every few seconds, and a mount that
|
||||
// cannot change would fill the log with the same line.
|
||||
const healFailureInterval = time.Minute
|
||||
|
||||
var (
|
||||
healFailureMu sync.Mutex
|
||||
healFailureLast time.Time
|
||||
)
|
||||
|
||||
// logHealFailure reports a failed heal, at most once per interval.
|
||||
func logHealFailure(err error) {
|
||||
healFailureMu.Lock()
|
||||
defer healFailureMu.Unlock()
|
||||
|
||||
if time.Since(healFailureLast) < healFailureInterval {
|
||||
return
|
||||
}
|
||||
healFailureLast = time.Now()
|
||||
logrus.Warnf("heal data partition: %s", err)
|
||||
}
|
||||
|
||||
// healDataPartition puts /data back to read-write when nothing is attached to
|
||||
// it. Two things leave it read-only with no owner: a release whose remount
|
||||
// failed, and the kernel itself switching the mount when the exFAT driver hit
|
||||
// an error (the mount carries errors=remount-ro). In both the target machine
|
||||
// cannot write the partition any more, so this side taking it back is safe -
|
||||
// and without it every upload, download and folder would fail with EROFS
|
||||
// until a reboot.
|
||||
func healDataPartition() error {
|
||||
mediumOperationMu.Lock()
|
||||
defer mediumOperationMu.Unlock()
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status.State == diskStatePartition {
|
||||
return nil
|
||||
}
|
||||
|
||||
return SetDataWritable(true)
|
||||
}
|
||||
|
||||
// DataDiskAttached reports whether the emulated disk serves the whole data
|
||||
// partition to the target machine, which is the state /data is read-only in.
|
||||
func DataDiskAttached() bool {
|
||||
return IsAttached(partitionDevice)
|
||||
}
|
||||
245
server/service/storage/diskfs/diskfs.go
Normal file
245
server/service/storage/diskfs/diskfs.go
Normal file
@@ -0,0 +1,245 @@
|
||||
// Package diskfs gives the file manager access to the filesystem inside a
|
||||
// medium. A medium is a disk image stored on the data partition, so the only
|
||||
// way in and out is a loop mount: the device kernel has exFAT and FAT built
|
||||
// in, and ships mkfs.exfat plus busybox mkdosfs, so both creating and mounting
|
||||
// image files stays in the kernel instead of a hand written FAT reader.
|
||||
//
|
||||
// The medium must not be attached to the target machine while it is mounted
|
||||
// here. Callers enforce that (see service/storage); this package only tracks
|
||||
// its own mounts so concurrent requests share one.
|
||||
package diskfs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/utils"
|
||||
)
|
||||
|
||||
// Kind is the filesystem a medium holds.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindExFAT Kind = "exfat"
|
||||
KindFAT32 Kind = "fat32"
|
||||
KindFAT16 Kind = "fat16"
|
||||
KindFAT12 Kind = "fat12"
|
||||
KindISO9660 Kind = "iso9660"
|
||||
KindUDF Kind = "udf"
|
||||
KindUnknown Kind = "unknown"
|
||||
)
|
||||
|
||||
// Browsable reports whether the file manager can list files inside the medium.
|
||||
// ISO 9660 is readable but read-only and not part of v1.
|
||||
func (k Kind) Browsable() bool {
|
||||
switch k {
|
||||
case KindExFAT, KindFAT32, KindFAT16, KindFAT12:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Optical reports whether the medium holds a disc filesystem, which is what a
|
||||
// CD-ROM has to carry: the gadget reports 2048 byte sectors for it.
|
||||
func (k Kind) Optical() bool {
|
||||
return k == KindISO9660 || k == KindUDF
|
||||
}
|
||||
|
||||
// FileSystem is the type name the kernel mount needs. vfat covers every FAT
|
||||
// variant (msdos is the subset without long names).
|
||||
func (k Kind) FileSystem() string {
|
||||
switch k {
|
||||
case KindExFAT:
|
||||
return "exfat"
|
||||
case KindFAT32, KindFAT16, KindFAT12:
|
||||
return "vfat"
|
||||
case KindISO9660:
|
||||
return "iso9660"
|
||||
case KindUDF:
|
||||
return "udf"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Entry is one directory entry inside a medium.
|
||||
type Entry struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"isDir"`
|
||||
ModTime time.Time `json:"modifiedAt"`
|
||||
}
|
||||
|
||||
// Caps is what the device can actually do. Mounting a medium needs a loop
|
||||
// device; the file manager reports a missing capability instead of failing
|
||||
// with a bare exec error, because a custom build may not have one.
|
||||
type Caps struct {
|
||||
Loop bool `json:"loop"`
|
||||
}
|
||||
|
||||
// ErrUnsupported reports a missing device capability.
|
||||
var ErrUnsupported = errors.New("unsupported on this device")
|
||||
|
||||
// ErrBusy reports a medium that cannot be handed out the way the caller
|
||||
// asked: a write meets a direct reader, or a direct read meets a writer.
|
||||
var ErrBusy = errors.New("the medium is busy")
|
||||
|
||||
// Probe reports the device's media capabilities. The answer cannot change
|
||||
// while the server runs, and the listing asks for it on every poll, so it is
|
||||
// computed once.
|
||||
func Probe() Caps {
|
||||
probeOnce.Do(func() {
|
||||
probedCaps = Caps{
|
||||
Loop: lookPath("losetup") && pathExists("/dev/loop-control"),
|
||||
}
|
||||
})
|
||||
|
||||
return probedCaps
|
||||
}
|
||||
|
||||
var (
|
||||
probeOnce sync.Once
|
||||
probedCaps Caps
|
||||
)
|
||||
|
||||
// Err describes what is missing, or nil when the device can browse media.
|
||||
func (c Caps) Err() error {
|
||||
if !c.Loop {
|
||||
return fmt.Errorf("%w: no loop device support", ErrUnsupported)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Detect reads the boot sector and reports the filesystem, so the UI knows
|
||||
// whether a medium can be browsed before anything is mounted.
|
||||
func Detect(imagePath string) (Kind, error) {
|
||||
file, err := os.Open(imagePath)
|
||||
if err != nil {
|
||||
return KindUnknown, err
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
boot := make([]byte, 512)
|
||||
if _, err := io.ReadFull(file, boot); err != nil {
|
||||
return KindUnknown, nil // too small to hold a filesystem
|
||||
}
|
||||
|
||||
if string(boot[3:11]) == "EXFAT " {
|
||||
return KindExFAT, nil
|
||||
}
|
||||
|
||||
if boot[510] == 0x55 && boot[511] == 0xaa {
|
||||
// A FAT32 volume also carries the legacy "FAT16" string at 0x36, so
|
||||
// the FAT32 field has to be looked at first.
|
||||
if fat := boot[0x52 : 0x52+8]; isFATLabel(fat) {
|
||||
return fatKind(string(fat)), nil
|
||||
}
|
||||
if fat := boot[0x36 : 0x36+8]; isFATLabel(fat) {
|
||||
return fatKind(string(fat)), nil
|
||||
}
|
||||
}
|
||||
|
||||
return detectOptical(file), nil
|
||||
}
|
||||
|
||||
// opticalSectorSize is the sector size of a disc, and udfVRSDescriptors the
|
||||
// number of volume structure descriptors looked at. A UDF disc keeps its
|
||||
// volume recognition sequence right after any ISO 9660 descriptors, and a
|
||||
// handful of sectors covers both.
|
||||
const (
|
||||
opticalSectorSize = 2048
|
||||
udfVRSDescriptors = 32
|
||||
)
|
||||
|
||||
// detectOptical walks the volume recognition area 32 KiB into the image. An
|
||||
// ISO 9660 primary descriptor ("CD001") in the first slot is the common case;
|
||||
// a pure UDF disc, which many recent installers ship as, has no ISO 9660
|
||||
// descriptor at all and is only recognized by its NSR02/NSR03 descriptor.
|
||||
func detectOptical(file io.ReaderAt) Kind {
|
||||
id := make([]byte, 5)
|
||||
for i := 0; i < udfVRSDescriptors; i++ {
|
||||
offset := int64(0x8000 + i*opticalSectorSize + 1)
|
||||
if _, err := file.ReadAt(id, offset); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
switch string(id) {
|
||||
case "CD001":
|
||||
if i == 0 {
|
||||
return KindISO9660
|
||||
}
|
||||
case "NSR02", "NSR03":
|
||||
return KindUDF
|
||||
case "BEA01", "TEA01", "BOOT2", "CDW02":
|
||||
default:
|
||||
// The sequence ends at the first slot that is no descriptor.
|
||||
return KindUnknown
|
||||
}
|
||||
}
|
||||
|
||||
return KindUnknown
|
||||
}
|
||||
|
||||
func isFATLabel(label []byte) bool {
|
||||
return len(label) == 8 && (string(label[:5]) == "FAT12" || string(label[:5]) == "FAT16" || string(label[:5]) == "FAT32")
|
||||
}
|
||||
|
||||
func fatKind(label string) Kind {
|
||||
switch label[:5] {
|
||||
case "FAT12":
|
||||
return KindFAT12
|
||||
case "FAT16":
|
||||
return KindFAT16
|
||||
default:
|
||||
return KindFAT32
|
||||
}
|
||||
}
|
||||
|
||||
func lookPath(name string) bool {
|
||||
_, err := exec.LookPath(name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func pathExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func trimOutput(out []byte) string {
|
||||
const limit = 200
|
||||
text := string(out)
|
||||
if len(text) > limit {
|
||||
text = text[:limit]
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// ResolveInside resolves rel inside root and refuses anything that escapes it.
|
||||
// Callers validate paths before this, so a failure here means a bug.
|
||||
func ResolveInside(root, rel string) (string, error) {
|
||||
if rel == "" || rel == "/" {
|
||||
return root, nil
|
||||
}
|
||||
|
||||
full := filepath.Join(root, filepath.FromSlash(rel))
|
||||
inside, err := filepath.Rel(root, full)
|
||||
if err != nil || inside == ".." || strings.HasPrefix(inside, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("%w: %s escapes the medium", utils.ErrInvalidMediaPath, rel)
|
||||
}
|
||||
|
||||
return full, nil
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
|
||||
}
|
||||
181
server/service/storage/diskfs/diskfs_test.go
Normal file
181
server/service/storage/diskfs/diskfs_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package diskfs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeImage(t *testing.T, descriptors ...string) string {
|
||||
t.Helper()
|
||||
image := make([]byte, 0x8000+(len(descriptors)+2)*opticalSectorSize)
|
||||
for i, id := range descriptors {
|
||||
copy(image[0x8000+i*opticalSectorSize+1:], id)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "disc.iso")
|
||||
if err := os.WriteFile(path, image, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestDetectOptical(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
descriptors []string
|
||||
want Kind
|
||||
}{
|
||||
{"iso9660", []string{"CD001", "CD001"}, KindISO9660},
|
||||
{"iso9660 with udf bridge", []string{"CD001", "BEA01", "NSR02", "TEA01"}, KindISO9660},
|
||||
{"pure udf 1.02", []string{"BEA01", "NSR02", "TEA01"}, KindUDF},
|
||||
{"pure udf 2.x", []string{"BEA01", "NSR03", "TEA01"}, KindUDF},
|
||||
{"extended area without nsr", []string{"BEA01", "TEA01"}, KindUnknown},
|
||||
{"nothing", nil, KindUnknown},
|
||||
{"nsr after a gap", []string{"BEA01", "", "NSR03"}, KindUnknown},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
kind, err := Detect(writeImage(t, tt.descriptors...))
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", tt.name, err)
|
||||
continue
|
||||
}
|
||||
if kind != tt.want {
|
||||
t.Errorf("%s: Detect = %q, want %q", tt.name, kind, tt.want)
|
||||
}
|
||||
if kind.Optical() != (tt.want == KindISO9660 || tt.want == KindUDF) {
|
||||
t.Errorf("%s: Optical = %v", tt.name, kind.Optical())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTransferTemp(t *testing.T) {
|
||||
tests := map[string]bool{
|
||||
".kvm-upload-0a1b2c3d": true,
|
||||
".kvm-upload-00000000": true,
|
||||
".kvm-upload-0A1B2C3D": false,
|
||||
".kvm-upload-0a1b2c3": false,
|
||||
".kvm-upload-notes.txt": false,
|
||||
".nanokvm-upload-123456789": true,
|
||||
".nanokvm-download-4294967295": true,
|
||||
".nanokvm-download-": false,
|
||||
".nanokvm-config": false,
|
||||
".nanokvm-upload-12ab": false,
|
||||
"ubuntu.iso": false,
|
||||
}
|
||||
for name, want := range tests {
|
||||
if got := IsTransferTemp(name); got != want {
|
||||
t.Errorf("IsTransferTemp(%q) = %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDirHidesTransferTemps(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"a.txt", ".kvm-upload-0a1b2c3d", ".nanokvm-download-42", ".nanokvm-mine"} {
|
||||
if err := os.WriteFile(filepath.Join(root, name), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := listDir(root, "/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name)
|
||||
}
|
||||
if len(names) != 2 || names[0] != ".nanokvm-mine" || names[1] != "a.txt" {
|
||||
t.Errorf("listDir = %v, want [.nanokvm-mine a.txt]", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFileRefusesSymlinks(t *testing.T) {
|
||||
outside := filepath.Join(t.TempDir(), "secret")
|
||||
if err := os.WriteFile(outside, []byte("secret"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "dir"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "file"), []byte("ok"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if file, _, err := openFile(root, "link"); err == nil {
|
||||
_ = file.Close()
|
||||
t.Error("openFile followed a symlink")
|
||||
}
|
||||
if file, _, err := openFile(root, "dir"); err == nil {
|
||||
_ = file.Close()
|
||||
t.Error("openFile opened a directory")
|
||||
}
|
||||
|
||||
file, entry, err := openFile(root, "file")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
data, _ := io.ReadAll(file)
|
||||
if string(data) != "ok" || entry.Size != 2 {
|
||||
t.Errorf("openFile read %q, size %d", data, entry.Size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMkdirDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
if err := mkdirDir(root, "photos"); err != nil {
|
||||
t.Fatalf("mkdirDir: %v", err)
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(root, "photos")); err != nil || !info.IsDir() {
|
||||
t.Fatalf("photos not created as a directory: %v", err)
|
||||
}
|
||||
|
||||
// Nested paths work the way the file manager builds them.
|
||||
if err := os.Mkdir(filepath.Join(root, "parent"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mkdirDir(root, "parent/child"); err != nil {
|
||||
t.Fatalf("mkdirDir nested: %v", err)
|
||||
}
|
||||
|
||||
// An existing name is reported, not replaced. The sentinel lets a caller
|
||||
// that recreates a whole tree treat it as success.
|
||||
if err := mkdirDir(root, "photos"); !errors.Is(err, ErrExists) {
|
||||
t.Errorf("mkdirDir on an existing directory = %v, want ErrExists", err)
|
||||
}
|
||||
if err := mkdirDir(root, "/"); err == nil {
|
||||
t.Error("mkdirDir accepted the medium root")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mkdirDir(root, "notes.txt"); err == nil || errors.Is(err, ErrExists) {
|
||||
t.Errorf("mkdirDir on a file = %v, want a plain not-a-directory error", err)
|
||||
}
|
||||
if err := mkdirDir(root, "../escape"); err == nil {
|
||||
t.Error("mkdirDir accepted a path outside the medium")
|
||||
}
|
||||
|
||||
// A symlinked directory is not a place to create anything: the entry would
|
||||
// land wherever the link points, outside the medium.
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mkdirDir(root, "link/escape"); err == nil {
|
||||
t.Error("mkdirDir followed a symlink out of the medium")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "escape")); err == nil {
|
||||
t.Error("mkdirDir created a directory outside the medium")
|
||||
}
|
||||
}
|
||||
685
server/service/storage/diskfs/mount.go
Normal file
685
server/service/storage/diskfs/mount.go
Normal file
@@ -0,0 +1,685 @@
|
||||
package diskfs
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"NanoKVM-Server/utils"
|
||||
)
|
||||
|
||||
// MountRoot holds the loop mounts while a medium is browsed. It lives under
|
||||
// /run next to the transfer lock: a reboot clears it, and it is not inside
|
||||
// /data where the medium listing walks every file.
|
||||
const MountRoot = "/run/nanokvm/media"
|
||||
|
||||
// loopDeviceLimit is the number of loop devices the kernel is configured with;
|
||||
// scanning them all is cheaper than parsing losetup output.
|
||||
const loopDeviceLimit = 8
|
||||
|
||||
// UploadPrefix marks a partially written upload inside a medium. It is deleted
|
||||
// on failure, renamed into place on success, and never handed out as a file.
|
||||
const UploadPrefix = ".kvm-upload-"
|
||||
|
||||
// DownloadTempPrefix is what the image downloader (service/download) names
|
||||
// the file it is writing while the transfer runs, through os.CreateTemp with
|
||||
// "upload-*" or "download-*" after it.
|
||||
const DownloadTempPrefix = ".nanokvm-"
|
||||
|
||||
// IsTransferTemp reports whether name is exactly what one of our transfers
|
||||
// names its partial file: UploadPrefix plus randomSuffix's eight hex digits,
|
||||
// or DownloadTempPrefix plus the decimal number os.CreateTemp puts in place of
|
||||
// the "*". A user's own file that merely starts the same way is not one.
|
||||
func IsTransferTemp(name string) bool {
|
||||
if rest, ok := strings.CutPrefix(name, UploadPrefix); ok {
|
||||
return len(rest) == 8 && strings.Trim(rest, "0123456789abcdef") == ""
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(name, DownloadTempPrefix); ok {
|
||||
for _, kind := range []string{"upload-", "download-"} {
|
||||
if digits, ok := strings.CutPrefix(rest, kind); ok {
|
||||
return digits != "" && strings.Trim(digits, "0123456789") == ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Image is a mounted medium. Handles are reference counted, because a download
|
||||
// may stream for minutes while the file list is refreshed, and both have to
|
||||
// see the same mount.
|
||||
type Image interface {
|
||||
List(rel string) ([]Entry, error)
|
||||
Open(rel string) (io.ReadSeekCloser, Entry, error)
|
||||
Write(rel string, r io.Reader) (int64, error)
|
||||
Mkdir(rel string) error
|
||||
Remove(rel string) error
|
||||
StatFS() (total, available uint64, err error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type mount struct {
|
||||
path string
|
||||
mountpoint string
|
||||
loopDevice string
|
||||
writable bool
|
||||
refs int
|
||||
}
|
||||
|
||||
var (
|
||||
mountsMu sync.Mutex
|
||||
mounts = map[string]*mount{}
|
||||
// readers counts direct readers of a medium's file: an image download
|
||||
// streams the file itself rather than a mount. A writable mount would
|
||||
// tear the stream those readers see, so it is refused while any of them
|
||||
// is active. Guarded by mountsMu, so the check and the mount stay one step.
|
||||
readers = map[string]int{}
|
||||
)
|
||||
|
||||
// Mount opens the filesystem inside a medium and returns a handle to it. The
|
||||
// caller is responsible for making sure the medium is not attached to the
|
||||
// target machine at the same time: two writers on one filesystem is how an
|
||||
// image gets destroyed.
|
||||
func Mount(imagePath string, writable bool) (Image, error) {
|
||||
if err := Probe().Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
kind, err := Detect(imagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !kind.Browsable() {
|
||||
return nil, fmt.Errorf("%w: %s holds no filesystem the file manager can browse", ErrUnsupported, imagePath)
|
||||
}
|
||||
|
||||
mountsMu.Lock()
|
||||
defer mountsMu.Unlock()
|
||||
|
||||
if existing, ok := mounts[imagePath]; ok {
|
||||
// Sharing a read-write mount for reading is fine; the other way round
|
||||
// is not, because the mount was set up read only.
|
||||
if writable && !existing.writable {
|
||||
return nil, fmt.Errorf("medium is already open for reading")
|
||||
}
|
||||
existing.refs++
|
||||
return &handle{mount: existing}, nil
|
||||
}
|
||||
|
||||
// A direct reader holds no mount, so it does not show up above; a write
|
||||
// would tear the stream it is reading.
|
||||
if writable && readers[imagePath] > 0 {
|
||||
return nil, fmt.Errorf("%w: %s is being read", ErrBusy, imagePath)
|
||||
}
|
||||
|
||||
attached, err := attach(imagePath, kind, writable)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attached.refs = 1
|
||||
mounts[imagePath] = attached
|
||||
|
||||
return &handle{mount: attached}, nil
|
||||
}
|
||||
|
||||
// AcquireReadPin marks a medium's file as being read directly, outside a
|
||||
// mount, and refuses while the file manager holds the medium open for
|
||||
// writing: the stream would otherwise serve a file that changes underneath
|
||||
// it. The returned function releases the pin and is safe to call twice.
|
||||
func AcquireReadPin(imagePath string) (func(), error) {
|
||||
mountsMu.Lock()
|
||||
defer mountsMu.Unlock()
|
||||
|
||||
if existing, ok := mounts[imagePath]; ok && existing.writable {
|
||||
return nil, fmt.Errorf("%w: %s is open for writing", ErrBusy, imagePath)
|
||||
}
|
||||
|
||||
readers[imagePath]++
|
||||
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
mountsMu.Lock()
|
||||
defer mountsMu.Unlock()
|
||||
|
||||
readers[imagePath]--
|
||||
if readers[imagePath] <= 0 {
|
||||
delete(readers, imagePath)
|
||||
}
|
||||
})
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MountedImages lists the media the file manager currently holds open.
|
||||
func MountedImages() []string {
|
||||
mountsMu.Lock()
|
||||
defer mountsMu.Unlock()
|
||||
|
||||
paths := make([]string, 0, len(mounts))
|
||||
for path := range mounts {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
// IsMounted reports whether the file manager currently holds a medium open.
|
||||
func IsMounted(imagePath string) bool {
|
||||
mountsMu.Lock()
|
||||
defer mountsMu.Unlock()
|
||||
|
||||
_, ok := mounts[imagePath]
|
||||
return ok
|
||||
}
|
||||
|
||||
func attach(imagePath string, kind Kind, writable bool) (*mount, error) {
|
||||
if err := os.MkdirAll(MountRoot, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create %s: %w", MountRoot, err)
|
||||
}
|
||||
|
||||
mountpoint := filepath.Join(MountRoot, mountName(imagePath))
|
||||
if err := os.MkdirAll(mountpoint, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create %s: %w", mountpoint, err)
|
||||
}
|
||||
|
||||
loopDevice, err := attachLoop(imagePath, writable)
|
||||
if err != nil {
|
||||
_ = os.Remove(mountpoint)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Neither exFAT nor FAT can hold a symlink, but the image is untrusted
|
||||
// and the server runs as root: refusing to follow one keeps any path
|
||||
// inside the mount from resolving outside it, whatever the filesystem.
|
||||
flags := uintptr(unix.MS_NOEXEC | unix.MS_NOSUID | unix.MS_NODEV | unix.MS_NOSYMFOLLOW)
|
||||
if !writable {
|
||||
flags |= unix.MS_RDONLY
|
||||
}
|
||||
|
||||
if err := unix.Mount(loopDevice, mountpoint, kind.FileSystem(), flags, ""); err != nil {
|
||||
detachLoop(loopDevice)
|
||||
_ = os.Remove(mountpoint)
|
||||
return nil, fmt.Errorf("mount %s as %s: %w", imagePath, kind, err)
|
||||
}
|
||||
|
||||
return &mount{
|
||||
path: imagePath,
|
||||
mountpoint: mountpoint,
|
||||
loopDevice: loopDevice,
|
||||
writable: writable,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// mountName builds a filesystem safe directory name that still says which
|
||||
// medium it belongs to.
|
||||
func mountName(imagePath string) string {
|
||||
sum := sha256.Sum256([]byte(imagePath))
|
||||
|
||||
base := filepath.Base(imagePath)
|
||||
base = strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-', r == '_':
|
||||
return r
|
||||
default:
|
||||
return '-'
|
||||
}
|
||||
}, base)
|
||||
|
||||
return base + "-" + hex.EncodeToString(sum[:4])
|
||||
}
|
||||
|
||||
func attachLoop(imagePath string, writable bool) (string, error) {
|
||||
device, err := freeLoopDevice()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
args := []string{}
|
||||
if !writable {
|
||||
args = append(args, "-r")
|
||||
}
|
||||
args = append(args, device, imagePath)
|
||||
|
||||
if out, err := exec.Command("losetup", args...).CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("losetup %s: %v: %s", imagePath, err, trimOutput(out))
|
||||
}
|
||||
|
||||
return device, nil
|
||||
}
|
||||
|
||||
func detachLoop(device string) {
|
||||
if device == "" {
|
||||
return
|
||||
}
|
||||
if out, err := exec.Command("losetup", "-d", device).CombinedOutput(); err != nil {
|
||||
logrus.Errorf("detach loop device %s failed: %s: %s", device, err, trimOutput(out))
|
||||
}
|
||||
}
|
||||
|
||||
func freeLoopDevice() (string, error) {
|
||||
for i := 0; i < loopDeviceLimit; i++ {
|
||||
name := fmt.Sprintf("loop%d", i)
|
||||
device := "/dev/" + name
|
||||
if !pathExists(device) {
|
||||
continue
|
||||
}
|
||||
|
||||
// This kernel only creates the loop attributes once the device has been
|
||||
// bound, so a missing backing_file is a free device rather than an
|
||||
// error.
|
||||
backing, err := os.ReadFile("/sys/block/" + name + "/loop/backing_file")
|
||||
if os.IsNotExist(err) {
|
||||
return device, nil
|
||||
}
|
||||
if err == nil && strings.TrimSpace(string(backing)) == "" {
|
||||
return device, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("no free loop device")
|
||||
}
|
||||
|
||||
type handle struct {
|
||||
mount *mount
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (h *handle) List(rel string) ([]Entry, error) {
|
||||
return listDir(h.mount.mountpoint, rel)
|
||||
}
|
||||
|
||||
func (h *handle) Open(rel string) (io.ReadSeekCloser, Entry, error) {
|
||||
return openFile(h.mount.mountpoint, rel)
|
||||
}
|
||||
|
||||
func (h *handle) Write(rel string, r io.Reader) (int64, error) {
|
||||
if !h.mount.writable {
|
||||
return 0, errors.New("medium is mounted read-only")
|
||||
}
|
||||
return writeFile(h.mount.mountpoint, rel, r)
|
||||
}
|
||||
|
||||
func (h *handle) Remove(rel string) error {
|
||||
return removeFile(h.mount.mountpoint, rel)
|
||||
}
|
||||
|
||||
func (h *handle) Mkdir(rel string) error {
|
||||
if !h.mount.writable {
|
||||
return errors.New("medium is mounted read-only")
|
||||
}
|
||||
return mkdirDir(h.mount.mountpoint, rel)
|
||||
}
|
||||
|
||||
func (h *handle) StatFS() (total, available uint64, err error) {
|
||||
return utils.Statfs(h.mount.mountpoint)
|
||||
}
|
||||
|
||||
// dirHandle is a medium that needs no loop device because it is already a
|
||||
// directory on a mounted filesystem: the data partition. The emulated disk
|
||||
// hands that same directory to the target machine, so the file manager reads
|
||||
// and writes exactly what the target machine sees.
|
||||
type dirHandle struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// OpenDirectory exposes root as a medium. Callers must make sure the target
|
||||
// machine is not using it at the same time; the file manager does that with
|
||||
// the disk state.
|
||||
func OpenDirectory(root string) (Image, error) {
|
||||
info, err := os.Stat(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("%s is not a directory", root)
|
||||
}
|
||||
|
||||
return &dirHandle{root: root}, nil
|
||||
}
|
||||
|
||||
func (h *dirHandle) List(rel string) ([]Entry, error) {
|
||||
return listDir(h.root, rel)
|
||||
}
|
||||
|
||||
func (h *dirHandle) Open(rel string) (io.ReadSeekCloser, Entry, error) {
|
||||
return openFile(h.root, rel)
|
||||
}
|
||||
|
||||
func (h *dirHandle) Write(rel string, r io.Reader) (int64, error) {
|
||||
return writeFile(h.root, rel, r)
|
||||
}
|
||||
|
||||
func (h *dirHandle) Remove(rel string) error {
|
||||
return removeFile(h.root, rel)
|
||||
}
|
||||
|
||||
func (h *dirHandle) Mkdir(rel string) error {
|
||||
return mkdirDir(h.root, rel)
|
||||
}
|
||||
|
||||
func (h *dirHandle) StatFS() (total, available uint64, err error) {
|
||||
return utils.Statfs(h.root)
|
||||
}
|
||||
|
||||
func (h *dirHandle) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func listDir(root, rel string) ([]Entry, error) {
|
||||
dir, err := ResolveInside(root, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dirents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries := make([]Entry, 0, len(dirents))
|
||||
for _, dirent := range dirents {
|
||||
// A partial transfer is not a file yet, and deleting it from the web
|
||||
// UI would lose the data being written.
|
||||
if IsTransferTemp(dirent.Name()) {
|
||||
continue
|
||||
}
|
||||
info, err := dirent.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entryOf(info))
|
||||
}
|
||||
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
if entries[i].IsDir != entries[j].IsDir {
|
||||
return entries[i].IsDir
|
||||
}
|
||||
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
|
||||
})
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func openFile(root, rel string) (io.ReadSeekCloser, Entry, error) {
|
||||
full, err := ResolveInside(root, rel)
|
||||
if err != nil {
|
||||
return nil, Entry{}, err
|
||||
}
|
||||
|
||||
// The entry is opened before it is looked at, and a symlink is refused
|
||||
// rather than followed, so what is checked is what gets streamed.
|
||||
file, err := os.OpenFile(full, os.O_RDONLY|unix.O_NOFOLLOW, 0)
|
||||
if err != nil {
|
||||
return nil, Entry{}, err
|
||||
}
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, Entry{}, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
_ = file.Close()
|
||||
return nil, Entry{}, fmt.Errorf("%s is not a regular file", rel)
|
||||
}
|
||||
|
||||
return file, entryOf(info), nil
|
||||
}
|
||||
|
||||
// Write stores r as rel inside the medium and reports how many bytes landed.
|
||||
// The bytes go to a hidden name first and are renamed into place, so a failed
|
||||
// or cancelled upload never leaves a half written file where the target
|
||||
// machine can see it.
|
||||
func writeFile(root, rel string, r io.Reader) (int64, error) {
|
||||
full, err := ResolveInside(root, rel)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := os.Stat(full); err == nil {
|
||||
return 0, fmt.Errorf("%s: %w", rel, ErrExists)
|
||||
}
|
||||
|
||||
tempPath := filepath.Join(filepath.Dir(full), UploadPrefix+randomSuffix())
|
||||
file, err := os.OpenFile(tempPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
written, err := io.Copy(file, r)
|
||||
if err == nil {
|
||||
err = file.Sync()
|
||||
}
|
||||
if closeErr := file.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if err := os.Rename(tempPath, full); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// ErrExists reports a name that is already taken inside the medium.
|
||||
var ErrExists = errors.New("already exists")
|
||||
|
||||
// mkdirDir creates rel as a directory inside the medium. It refuses to replace
|
||||
// anything: a name the target machine or the user already put there is a
|
||||
// mistake to report, not to overwrite.
|
||||
func mkdirDir(root, rel string) error {
|
||||
full, err := ResolveInside(root, rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if full == root {
|
||||
return errors.New("refusing to create the medium root")
|
||||
}
|
||||
// ResolveInside is lexical. A directory the target machine put there can be
|
||||
// a symlink elsewhere, and a create through it would land outside the
|
||||
// medium; the parent is what has to be real, since the entry does not exist
|
||||
// yet.
|
||||
if err := ensureInside(root, filepath.Dir(full)); err != nil {
|
||||
return err
|
||||
}
|
||||
if info, err := os.Stat(full); err == nil {
|
||||
// A name taken by a file is not a folder that is already there: the
|
||||
// caller's tree upload would otherwise treat it as success and fail on
|
||||
// every file it puts inside.
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("%s is not a directory", rel)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", rel, ErrExists)
|
||||
}
|
||||
if err := os.Mkdir(full, 0o755); err != nil {
|
||||
return fmt.Errorf("create directory %s: %w", rel, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureInside refuses a directory whose real path leaves root through a
|
||||
// symlink. Both sides are resolved, so a medium that is itself reached through
|
||||
// a link still compares correctly.
|
||||
func ensureInside(root, dir string) error {
|
||||
realRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
realDir, err := filepath.EvalSymlinks(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
inside, err := filepath.Rel(realRoot, realDir)
|
||||
if err != nil || inside == ".." || strings.HasPrefix(inside, ".."+string(filepath.Separator)) {
|
||||
return fmt.Errorf("%w: %s leaves the medium", utils.ErrInvalidMediaPath, dir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeFile(root, rel string) error {
|
||||
full, err := ResolveInside(root, rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if full == root {
|
||||
return errors.New("refusing to remove the medium root")
|
||||
}
|
||||
|
||||
// A directory goes with everything it holds: the web UI confirms the
|
||||
// delete first, and answering an explicit yes with "directory not empty"
|
||||
// helps no one. ResolveInside keeps the walk inside the medium.
|
||||
return os.RemoveAll(full)
|
||||
}
|
||||
|
||||
func (h *handle) Close() error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if h.closed {
|
||||
return nil
|
||||
}
|
||||
h.closed = true
|
||||
|
||||
mountsMu.Lock()
|
||||
defer mountsMu.Unlock()
|
||||
|
||||
h.mount.refs--
|
||||
if h.mount.refs > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
delete(mounts, h.mount.path)
|
||||
return detach(h.mount)
|
||||
}
|
||||
|
||||
func detach(m *mount) error {
|
||||
var errs []error
|
||||
|
||||
if err := unix.Unmount(m.mountpoint, 0); err != nil {
|
||||
// A medium the file manager still holds open by accident must not keep
|
||||
// the loop device busy forever.
|
||||
if lazyErr := unix.Unmount(m.mountpoint, unix.MNT_DETACH); lazyErr != nil {
|
||||
errs = append(errs, fmt.Errorf("unmount %s: %w", m.mountpoint, lazyErr))
|
||||
}
|
||||
}
|
||||
|
||||
detachLoop(m.loopDevice)
|
||||
_ = os.Remove(m.mountpoint)
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// CleanupStale detaches every mount and loop device left behind under the
|
||||
// mount root. At startup nothing can be mounted by this process yet, so
|
||||
// whatever is there is a leftover from a crash, a restart or an update; at
|
||||
// shutdown it releases the mounts this process still holds.
|
||||
func CleanupStale() {
|
||||
mountsMu.Lock()
|
||||
defer mountsMu.Unlock()
|
||||
|
||||
for _, mountpoint := range staleMountpoints() {
|
||||
if err := unix.Unmount(mountpoint, unix.MNT_DETACH); err != nil {
|
||||
logrus.Errorf("cleanup stale mount %s failed: %s", mountpoint, err)
|
||||
}
|
||||
// The directory is ours to remove; a non-empty one is left alone.
|
||||
_ = os.Remove(mountpoint)
|
||||
}
|
||||
|
||||
for _, device := range loopsBackingMedia() {
|
||||
detachLoop(device)
|
||||
}
|
||||
|
||||
// Mount points are created per medium and only exist while it is mounted,
|
||||
// so whatever is still in the root is a leftover. Removing a directory
|
||||
// fails while something is mounted on it, which is the safety we want.
|
||||
if entries, err := os.ReadDir(MountRoot); err == nil {
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
_ = os.Remove(filepath.Join(MountRoot, entry.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mounts = map[string]*mount{}
|
||||
}
|
||||
|
||||
func staleMountpoints() []string {
|
||||
data, err := os.ReadFile("/proc/mounts")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var paths []string
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
// /proc escapes spaces in mount points; our names never contain any.
|
||||
mountpoint := fields[1]
|
||||
if mountpoint == MountRoot || strings.HasPrefix(mountpoint, MountRoot+"/") {
|
||||
paths = append(paths, mountpoint)
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
// loopsBackingMedia finds loop devices whose backing file is a medium, so a
|
||||
// leftover device is released even when its mount is already gone.
|
||||
func loopsBackingMedia() []string {
|
||||
var devices []string
|
||||
|
||||
for i := 0; i < loopDeviceLimit; i++ {
|
||||
name := fmt.Sprintf("loop%d", i)
|
||||
backing, err := os.ReadFile("/sys/block/" + name + "/loop/backing_file")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(string(backing))
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if path == utils.MediaDirectory || strings.HasPrefix(path, utils.MediaDirectory+"/") {
|
||||
devices = append(devices, "/dev/"+name)
|
||||
}
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
func entryOf(info os.FileInfo) Entry {
|
||||
return Entry{
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
IsDir: info.IsDir(),
|
||||
ModTime: info.ModTime(),
|
||||
}
|
||||
}
|
||||
|
||||
func randomSuffix() string {
|
||||
buf := make([]byte, 4)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "00000000"
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
560
server/service/storage/files.go
Normal file
560
server/service/storage/files.go
Normal file
@@ -0,0 +1,560 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/service/storage/diskfs"
|
||||
"NanoKVM-Server/utils"
|
||||
)
|
||||
|
||||
// File handlers work inside a medium that is not attached to the target
|
||||
// machine. A medium the target machine owns is refused by openMedium: the two
|
||||
// of us writing one filesystem is how it gets destroyed.
|
||||
|
||||
// requireFileWrite picks the grant the medium in the request needs. One path
|
||||
// serves two media types: an image holds library content (library.write), while
|
||||
// the shared partition is what the emulated disk hands to the target machine, so
|
||||
// writing into it belongs to medium.attach. The route admits either grant and
|
||||
// this is the only place the pair is narrowed, because which one applies is not
|
||||
// visible to a path-based rule.
|
||||
func requireFileWrite(c *gin.Context, mediaType string) bool {
|
||||
permission := authn.PermLibraryWrite
|
||||
if mediaType == proto.MediaTypePartition {
|
||||
permission = authn.PermMediumAttach
|
||||
}
|
||||
|
||||
principal, ok := middleware.CurrentPrincipal(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, "unauthorized")
|
||||
return false
|
||||
}
|
||||
if principal.Has(permission) {
|
||||
return true
|
||||
}
|
||||
|
||||
c.JSON(http.StatusForbidden, "forbidden")
|
||||
return false
|
||||
}
|
||||
|
||||
// requireLibraryWrite guards a write through the shared partition that changes
|
||||
// the library. The library is every .iso and .img on the volume, wherever it
|
||||
// was put (findImages walks all of /data), so creating, replacing or removing
|
||||
// one through the partition changes it as much as the library endpoints do,
|
||||
// and medium.attach alone must not reach that far.
|
||||
func requireLibraryWrite(c *gin.Context) bool {
|
||||
principal, ok := middleware.CurrentPrincipal(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, "unauthorized")
|
||||
return false
|
||||
}
|
||||
if principal.Has(authn.PermLibraryWrite) {
|
||||
return true
|
||||
}
|
||||
|
||||
c.JSON(http.StatusForbidden, "forbidden")
|
||||
return false
|
||||
}
|
||||
|
||||
// holdsMedia reports whether full is a disk image or a directory with one
|
||||
// anywhere below it.
|
||||
func holdsMedia(full string) (bool, error) {
|
||||
info, err := os.Stat(full)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return utils.IsMediaFile(full), nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return utils.IsMediaFile(info.Name()), nil
|
||||
}
|
||||
|
||||
found := false
|
||||
err = filepath.WalkDir(full, func(_ string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !entry.IsDir() && utils.IsMediaFile(entry.Name()) {
|
||||
found = true
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return found, err
|
||||
}
|
||||
|
||||
// uploadMaxBytes is a hard ceiling for a single upload, so a runaway request
|
||||
// cannot fill the medium before the free space check has had its say.
|
||||
const uploadMaxBytes = 8 << 30
|
||||
|
||||
func (s *Service) ListMediaFiles(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
mediaType := c.Query("type")
|
||||
rel := c.Query("rel")
|
||||
dir, err := utils.ValidateMediumPath(c.Query("path"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if mediaType == proto.MediaTypePartition {
|
||||
// The browser tells the watcher every directory it is showing: the one
|
||||
// it listed plus the folders it has expanded. Watching only the last
|
||||
// one left the others stale until a manual refresh.
|
||||
SetPartitionWatchPaths(watchPaths(dir, c.Query("watch")))
|
||||
invalidatePartitionCache()
|
||||
}
|
||||
// An image listing deliberately leaves that set alone: another browser may
|
||||
// still be showing the shared folder, and clearing it would stop its
|
||||
// updates until the next listing.
|
||||
|
||||
image, err := openMedium(mediaType, rel, false)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
defer closeMedia(image)
|
||||
|
||||
entries, err := image.List(dir)
|
||||
if err != nil {
|
||||
respondError(c, fmt.Errorf("list %s: %w", dir, err))
|
||||
return
|
||||
}
|
||||
|
||||
files := make([]proto.MediaFile, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
files = append(files, proto.MediaFile{
|
||||
Name: entry.Name,
|
||||
Size: entry.Size,
|
||||
IsDir: entry.IsDir,
|
||||
Modified: entry.ModTime,
|
||||
})
|
||||
}
|
||||
|
||||
total, free, _ := image.StatFS()
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetMediaFilesRsp{
|
||||
Path: dir,
|
||||
Entries: files,
|
||||
Free: free,
|
||||
Total: total,
|
||||
Writing: mediaType == proto.MediaTypePartition && partitionWriting(),
|
||||
})
|
||||
|
||||
log.Debugf("list media files success, %s/%s total %d", rel, dir, len(files))
|
||||
}
|
||||
|
||||
func (s *Service) DownloadMediaFile(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
rel := c.Query("rel")
|
||||
entryPath, err := utils.ValidateMediumPath(c.Query("path"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
if entryPath == "/" {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "a file path is required")
|
||||
return
|
||||
}
|
||||
|
||||
if c.Query("type") == proto.MediaTypePartition {
|
||||
invalidatePartitionCache()
|
||||
}
|
||||
|
||||
image, err := openMedium(c.Query("type"), rel, false)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
defer closeMedia(image)
|
||||
|
||||
file, entry, err := image.Open(entryPath)
|
||||
if err != nil {
|
||||
respondError(c, fmt.Errorf("open %s: %w", entryPath, err))
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
// The download streams straight out of the mounted image; ServeContent adds
|
||||
// range support and the headers a browser download needs.
|
||||
c.Header("Content-Disposition", attachmentHeader(path.Base(entryPath)))
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
http.ServeContent(c.Writer, c.Request, entry.Name, entry.ModTime, file)
|
||||
}
|
||||
|
||||
func (s *Service) UploadMediaFile(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
// The medium travels in the query string so the multipart body holds
|
||||
// nothing but the file: reading a form field would make net/http buffer the
|
||||
// whole upload in memory before the medium could even be opened.
|
||||
mediaType := c.Query("type")
|
||||
rel := c.Query("rel")
|
||||
dir, err := utils.ValidateMediumPath(c.Query("path"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Content-Length covers the multipart framing as well, so it is an upper
|
||||
// bound on the file: good enough to refuse an upload that cannot fit.
|
||||
size := c.Request.ContentLength
|
||||
|
||||
if !requireFileWrite(c, mediaType) {
|
||||
return
|
||||
}
|
||||
|
||||
image, err := openMedium(mediaType, rel, true)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
defer closeMedia(image)
|
||||
|
||||
// A chunked request states no length, so the free space is what has to cap
|
||||
// it instead: without that, an upload that never ends could fill the medium.
|
||||
limit := int64(uploadMaxBytes)
|
||||
limitedBySpace := false
|
||||
if _, free, err := image.StatFS(); err == nil {
|
||||
if size > 0 {
|
||||
if uint64(size) > free {
|
||||
respondError(c, fmt.Errorf("%w: the medium has %d MiB free", errNoSpace, free>>20))
|
||||
return
|
||||
}
|
||||
} else if free < uint64(limit) {
|
||||
limit = int64(free)
|
||||
limitedBySpace = true
|
||||
}
|
||||
}
|
||||
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
|
||||
|
||||
reader, err := c.Request.MultipartReader()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "invalid multipart request")
|
||||
return
|
||||
}
|
||||
|
||||
part, err := nextFilePart(reader)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = part.Close()
|
||||
}()
|
||||
|
||||
name, err := uploadName(part.FileName())
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
target := name
|
||||
if dir != "/" {
|
||||
target = path.Join(dir, name)
|
||||
}
|
||||
|
||||
// exFAT folds case with its own table, so the name in the request is not
|
||||
// necessarily the file it replaces: check the on-disk spelling as well.
|
||||
if mediaType == proto.MediaTypePartition {
|
||||
touchesLibrary := utils.IsMediaFile(name)
|
||||
if !touchesLibrary {
|
||||
full, err := utils.CanonicalPathInside(mediaDirectory, target)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
touchesLibrary = utils.IsMediaFile(full)
|
||||
}
|
||||
if touchesLibrary && !requireLibraryWrite(c) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// An upload is a long transfer like any other: it takes the single job
|
||||
// slot so the web UI can report progress and nothing else writes to the
|
||||
// data partition while it runs.
|
||||
done, err := s.beginJob(c.Request.Context(), target)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// The upload is read from rather than written to, so the progress writer
|
||||
// only counts: the bytes go to the medium through the tee.
|
||||
progress := utils.NewProgressWriter(io.Discard, size, func(loaded, total int64) {
|
||||
s.setJobProgress(done, loaded, total)
|
||||
})
|
||||
defer progress.Stop()
|
||||
|
||||
written, err := image.Write(target, io.TeeReader(part, progress))
|
||||
if err != nil {
|
||||
s.finishJob(done, jobStateFailed, err.Error())
|
||||
|
||||
// The UI translates this one: refusing to overwrite is a normal answer
|
||||
// to an upload, not a failure whose English text needs to be shown.
|
||||
if errors.Is(err, diskfs.ErrExists) {
|
||||
rsp.ErrRsp(c, codeAlreadyExists, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var tooLarge *http.MaxBytesError
|
||||
if limitedBySpace && errors.As(err, &tooLarge) {
|
||||
respondError(c, fmt.Errorf("%w: the medium could not hold the upload", errNoSpace))
|
||||
return
|
||||
}
|
||||
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
s.finishJob(done, jobStateSuccess, "")
|
||||
|
||||
rsp.OkRspWithData(c, &proto.MediaFile{Name: name, Size: written})
|
||||
log.Debugf("upload %s to medium %s success", target, rel)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteMediaFile(c *gin.Context) {
|
||||
var req proto.MediaPathReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
|
||||
return
|
||||
}
|
||||
if !requireFileWrite(c, req.Type) {
|
||||
return
|
||||
}
|
||||
|
||||
entryPath, err := utils.ValidateMediumPath(req.Path)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
if entryPath == "/" {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "a file path is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Our own transfer temporaries are not files to manage while a transfer
|
||||
// runs: deleting one its transfer is writing loses the data silently. One
|
||||
// found with no transfer running was left by an interrupted one, and the
|
||||
// user may clear it.
|
||||
if name := path.Base(entryPath); diskfs.IsTransferTemp(name) && TransferInProgress() {
|
||||
respondError(c, fmt.Errorf("%w: %s belongs to a running transfer", errMediumBusy, name))
|
||||
return
|
||||
}
|
||||
|
||||
// A directory is removed with everything below it, the temporary of an
|
||||
// upload into it included.
|
||||
if target, ok := s.activeJobTarget(); ok && pathWithin(target, entryPath) {
|
||||
respondError(c, fmt.Errorf("%w: an upload into %s is running", errMediumBusy, entryPath))
|
||||
return
|
||||
}
|
||||
|
||||
// Deleting through the shared partition has no loop mount to keep the
|
||||
// gadget away. Hold the operation lock until the unlink is done so a
|
||||
// concurrent mount cannot attach the file after the attachment check.
|
||||
mediumOperationMu.Lock()
|
||||
defer mediumOperationMu.Unlock()
|
||||
|
||||
// A file the target machine is being served as its medium must not vanish
|
||||
// under it: the gadget keeps reading the inode it opened, so the target
|
||||
// machine would be left with a medium whose contents are already gone. An
|
||||
// image the file manager has mounted is just as much in use. A directory
|
||||
// is removed recursively, so the check covers everything below the path as
|
||||
// well. The path is resolved to its on-disk spelling first: exFAT folds
|
||||
// case, so "IMAGES" is the directory the attached "images/win.iso" is in.
|
||||
if req.Type == proto.MediaTypePartition {
|
||||
full, err := utils.CanonicalPathInside(mediaDirectory, entryPath)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// Not knowing what the gadget serves is no reason to assume nothing.
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
respondError(c, fmt.Errorf("refusing to delete %s: %w", entryPath, err))
|
||||
return
|
||||
}
|
||||
if err := deleteGuard(full, status.Backing, diskfs.MountedImages()); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
touchesLibrary, err := holdsMedia(full)
|
||||
if err != nil {
|
||||
respondError(c, fmt.Errorf("refusing to delete %s: %w", entryPath, err))
|
||||
return
|
||||
}
|
||||
if touchesLibrary && !requireLibraryWrite(c) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
image, err := openMediumLocked(req.Type, req.Rel, true)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
defer closeMedia(image)
|
||||
|
||||
if err := image.Remove(entryPath); err != nil {
|
||||
respondError(c, fmt.Errorf("remove %s: %w", entryPath, err))
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("delete %s from medium %s success", entryPath, req.Rel)
|
||||
}
|
||||
|
||||
// MkdirMediaFile creates a directory inside a medium. The web file manager
|
||||
// offers it next to the upload button: a folder the user can drop files into
|
||||
// is how a disk that started empty becomes organised. It is a write like any
|
||||
// other, so it goes through openMedium and is refused while the target machine
|
||||
// owns the partition.
|
||||
func (s *Service) MkdirMediaFile(c *gin.Context) {
|
||||
var req proto.MediaPathReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
|
||||
return
|
||||
}
|
||||
if !requireFileWrite(c, req.Type) {
|
||||
return
|
||||
}
|
||||
|
||||
entryPath, err := utils.ValidateMediumPath(req.Path)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
if entryPath == "/" {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "a directory path is required")
|
||||
return
|
||||
}
|
||||
|
||||
image, err := openMedium(req.Type, req.Rel, true)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
defer closeMedia(image)
|
||||
|
||||
if err := image.Mkdir(entryPath); err != nil {
|
||||
if errors.Is(err, diskfs.ErrExists) {
|
||||
if !req.ExistsOK {
|
||||
rsp.ErrRsp(c, codeAlreadyExists, fmt.Sprintf("%s already exists", entryPath))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
respondError(c, fmt.Errorf("create %s: %w", entryPath, err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// The shared partition has a metadata cache the listing reads; the
|
||||
// directory has to show up in the next list.
|
||||
if req.Type == proto.MediaTypePartition {
|
||||
invalidatePartitionCache()
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("create directory %s in medium %s success", entryPath, req.Rel)
|
||||
}
|
||||
|
||||
// maxWatchPaths caps the directories a single browser can ask the watcher to
|
||||
// read every second. The file manager expands a handful at most.
|
||||
const maxWatchPaths = 64
|
||||
|
||||
// watchPaths reads the directories the file browser is showing: the one being
|
||||
// listed plus every folder it has expanded. Anything that does not name a
|
||||
// directory inside the medium is dropped.
|
||||
func watchPaths(listed string, raw string) []string {
|
||||
paths := []string{listed}
|
||||
seen := map[string]bool{listed: true}
|
||||
|
||||
var candidates []string
|
||||
if raw = strings.TrimSpace(raw); raw != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &candidates); err != nil {
|
||||
// An older client sent a comma separated list; a folder name may
|
||||
// contain a comma, which is exactly why JSON is used now.
|
||||
candidates = strings.Split(raw, ",")
|
||||
}
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
cleaned, err := utils.ValidateMediumPath(candidate)
|
||||
if err != nil || seen[cleaned] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[cleaned] = true
|
||||
paths = append(paths, cleaned)
|
||||
if len(paths) >= maxWatchPaths {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
// nextFilePart walks the multipart body to the uploaded file, skipping any
|
||||
// field that came before it.
|
||||
func nextFilePart(reader *multipart.Reader) (*multipart.Part, error) {
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
return nil, fmt.Errorf("%w: no file in the request", utils.ErrInvalidMediaPath)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read multipart request: %w", err)
|
||||
}
|
||||
if part.FormName() != "file" {
|
||||
_ = part.Close()
|
||||
continue
|
||||
}
|
||||
return part, nil
|
||||
}
|
||||
}
|
||||
|
||||
// uploadName reduces the browser supplied file name to something the medium
|
||||
// can hold. The client picks it, so it is treated as untrusted.
|
||||
func uploadName(raw string) (string, error) {
|
||||
name := filepath.Base(raw)
|
||||
if err := utils.ValidateFileName(name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func closeMedia(image io.Closer) {
|
||||
if err := image.Close(); err != nil {
|
||||
log.Errorf("close medium failed: %s", err)
|
||||
}
|
||||
}
|
||||
72
server/service/storage/files_test.go
Normal file
72
server/service/storage/files_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWatchPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
listed string
|
||||
raw string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "listed directory is always watched",
|
||||
listed: "/",
|
||||
raw: "",
|
||||
want: []string{"/"},
|
||||
},
|
||||
{
|
||||
// The handler passes the already cleaned path it is listing; the
|
||||
// browser's expanded folders are cleaned the same way.
|
||||
name: "expanded folders are added",
|
||||
listed: "share",
|
||||
raw: `["/share/a","/share/b"]`,
|
||||
want: []string{"share", "share/a", "share/b"},
|
||||
},
|
||||
{
|
||||
// A comma is legal in a folder name, which is why the list is JSON.
|
||||
name: "a comma stays inside the name",
|
||||
listed: "/",
|
||||
raw: `["/a,b"]`,
|
||||
want: []string{"/", "a,b"},
|
||||
},
|
||||
{
|
||||
name: "duplicates and blanks are dropped",
|
||||
listed: "share",
|
||||
raw: `["/share","","/share/a","/share/a"]`,
|
||||
want: []string{"share", "share/a"},
|
||||
},
|
||||
{
|
||||
name: "a plain comma list still parses",
|
||||
listed: "/",
|
||||
raw: "/a,/b",
|
||||
want: []string{"/", "a", "b"},
|
||||
},
|
||||
{
|
||||
name: "paths that escape the medium are dropped",
|
||||
listed: "/",
|
||||
raw: `["../etc","/share/../../etc"]`,
|
||||
want: []string{"/"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := watchPaths(tt.listed, tt.raw); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("%s: watchPaths(%q, %q) = %v, want %v", tt.name, tt.listed, tt.raw, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchPathsCap(t *testing.T) {
|
||||
raw := ""
|
||||
for i := 0; i < maxWatchPaths+10; i++ {
|
||||
raw += "/dir" + string(rune('a'+i%26)) + string(rune('a'+i/26)) + ","
|
||||
}
|
||||
|
||||
if got := len(watchPaths("/", raw)); got > maxWatchPaths {
|
||||
t.Errorf("watchPaths kept %d entries, want at most %d", got, maxWatchPaths)
|
||||
}
|
||||
}
|
||||
391
server/service/storage/gadget.go
Normal file
391
server/service/storage/gadget.go
Normal file
@@ -0,0 +1,391 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"NanoKVM-Server/service/hid"
|
||||
"NanoKVM-Server/utils"
|
||||
)
|
||||
|
||||
// The emulated USB disk is one LUN of the configfs mass storage function.
|
||||
// Everything here is sysfs poking, and the kernel owns the rules:
|
||||
//
|
||||
// - writing an empty value to lun.0/file detaches the medium (the target
|
||||
// machine sees a media removal), writing a path attaches one;
|
||||
// - the read-only flag can only change while no medium is attached, so a
|
||||
// mount always goes eject -> flags -> inquiry -> file;
|
||||
// - a host that locked the medium (prevent_medium_removal, which Windows
|
||||
// sets while the disk is mounted) makes both fail with EBUSY.
|
||||
const (
|
||||
gadgetRoot = "/sys/kernel/config/usb_gadget/g0"
|
||||
gadgetUDC = gadgetRoot + "/UDC"
|
||||
gadgetUDCDir = "/sys/class/udc"
|
||||
massStorageFunc = gadgetRoot + "/functions/mass_storage.disk0"
|
||||
massStorageLink = gadgetRoot + "/configs/c.1/mass_storage.disk0"
|
||||
lunCDRomFlag = massStorageFunc + "/lun.0/cdrom"
|
||||
lunMountDevice = massStorageFunc + "/lun.0/file"
|
||||
lunInquiry = massStorageFunc + "/lun.0/inquiry_string"
|
||||
lunROFlag = massStorageFunc + "/lun.0/ro"
|
||||
// usbProductAttr is the device name the target machine shows. An image is
|
||||
// named after its file, the shared partition keeps the product name.
|
||||
usbProductAttr = gadgetRoot + "/strings/0x409/product"
|
||||
)
|
||||
|
||||
const (
|
||||
usbProductName = "NanoKVM"
|
||||
// The SCSI inquiry product field holds 16 bytes, the USB product string 126.
|
||||
inquiryProductSize = 16
|
||||
usbProductSize = 126
|
||||
)
|
||||
|
||||
// ErrMediumLocked reports that the target machine is still using the medium.
|
||||
// It is a normal condition, not a failure: the user has to eject the disk
|
||||
// there before the web UI can take it back.
|
||||
var ErrMediumLocked = errors.New("the target machine is using the medium")
|
||||
|
||||
type diskState string
|
||||
|
||||
const (
|
||||
diskStateNone diskState = "none" // no medium attached
|
||||
diskStateImage diskState = "image" // an image file is attached
|
||||
diskStatePartition diskState = "partition" // the whole data partition is attached
|
||||
diskStateUnsupported diskState = "unsupported" // no mass storage function (virtual disk off, or HID-only)
|
||||
)
|
||||
|
||||
// diskStatus is what the gadget exposes to the target machine right now. The
|
||||
// backing file is the single source of truth: both the UI and the file manager
|
||||
// read it instead of keeping their own idea of what is mounted.
|
||||
type diskStatus struct {
|
||||
State diskState
|
||||
Backing string
|
||||
HidOnly bool
|
||||
}
|
||||
|
||||
// lunConfig is the LUN configuration a mount request results in.
|
||||
type lunConfig struct {
|
||||
backing string
|
||||
ro bool
|
||||
cdrom bool
|
||||
}
|
||||
|
||||
func readDiskStatus() (diskStatus, error) {
|
||||
mode, err := hid.GetMode()
|
||||
hidOnly := err == nil && mode == hid.ModeHidOnly
|
||||
|
||||
// Switching the virtual disk off removes the link that exposes the
|
||||
// function to the target machine, while the function itself stays behind
|
||||
// with whatever medium was attached to it. The link is therefore what says
|
||||
// whether a medium would actually be visible, and without it a mount would
|
||||
// look like it worked and change nothing.
|
||||
if !pathExists(massStorageFunc) || !pathExists(massStorageLink) {
|
||||
return diskStatus{State: diskStateUnsupported, HidOnly: hidOnly}, nil
|
||||
}
|
||||
|
||||
config, err := readLunConfig()
|
||||
if err != nil {
|
||||
return diskStatus{}, err
|
||||
}
|
||||
|
||||
status := diskStatus{HidOnly: hidOnly}
|
||||
switch {
|
||||
case config.backing == "":
|
||||
status.State = diskStateNone
|
||||
case config.backing == partitionDevice:
|
||||
status.State = diskStatePartition
|
||||
status.Backing = config.backing
|
||||
default:
|
||||
status.State = diskStateImage
|
||||
status.Backing = config.backing
|
||||
// A running gadget may still report an alias used before this process
|
||||
// started. Normalize it too, so every operation compares the same
|
||||
// exFAT directory entry rather than just the same string.
|
||||
if canonical, err := utils.CanonicalMediaPath(config.backing); err == nil {
|
||||
status.Backing = canonical
|
||||
}
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func pathExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func readLunConfig() (lunConfig, error) {
|
||||
backing, err := readLunAttr(lunMountDevice)
|
||||
if err != nil {
|
||||
return lunConfig{}, err
|
||||
}
|
||||
|
||||
ro, err := readLunBool(lunROFlag)
|
||||
if err != nil {
|
||||
return lunConfig{}, err
|
||||
}
|
||||
|
||||
cdrom, err := readLunBool(lunCDRomFlag)
|
||||
if err != nil {
|
||||
return lunConfig{}, err
|
||||
}
|
||||
|
||||
return lunConfig{backing: backing, ro: ro, cdrom: cdrom}, nil
|
||||
}
|
||||
|
||||
func readLunAttr(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
func readLunBool(path string) (bool, error) {
|
||||
value, err := readLunAttr(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return value == "1", nil
|
||||
}
|
||||
|
||||
// eject detaches the medium, which the target machine sees as a media removal.
|
||||
func eject() error {
|
||||
// The medium goes first. While it is attached the kernel refuses ro but
|
||||
// takes cdrom=0 and a new name, so resetting those first would relabel a
|
||||
// medium the target machine still holds whenever the detach itself then
|
||||
// fails with EBUSY. A zero length write never reaches the kernel, so an
|
||||
// empty line is what actually detaches the medium.
|
||||
if err := writeLunAttr(lunMountDevice, "\n"); err != nil {
|
||||
if errors.Is(err, syscall.EBUSY) {
|
||||
return ErrMediumLocked
|
||||
}
|
||||
return fmt.Errorf("eject medium: %w", err)
|
||||
}
|
||||
|
||||
// The flags outlive the medium: the kernel only lets ro change while
|
||||
// nothing is attached, and a stale CD-ROM flag would describe the next
|
||||
// medium wrongly (a restart of the boot script resets them for the same
|
||||
// reason). The device also keeps the name it was built with while nothing
|
||||
// is attached.
|
||||
if err := writeLunAttr(lunInquiry, inquiryData("USB Mass Storage")); err != nil {
|
||||
log.Warnf("restore inquiry string failed: %s", err)
|
||||
}
|
||||
if err := writeLunAttr(usbProductAttr, usbProductName); err != nil {
|
||||
log.Warnf("restore usb product failed: %s", err)
|
||||
}
|
||||
if err := writeLunAttr(lunROFlag, boolAttr(false)); err != nil {
|
||||
log.Warnf("reset read-only flag failed: %s", err)
|
||||
}
|
||||
if err := writeLunAttr(lunCDRomFlag, boolAttr(false)); err != nil {
|
||||
log.Warnf("reset cdrom flag failed: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// attach runs the sequence the kernel requires. The read-only flag can only be
|
||||
// written while no medium is attached, and both it and the CD-ROM flag force a
|
||||
// detach of their own, so the order is fixed and every flag is written on
|
||||
// every mount: leaving a stale cdrom=1 behind is how an image file ends up
|
||||
// presented to the target as a CD-ROM.
|
||||
func attach(backing string, cdrom, ro bool) error {
|
||||
if backing == "" {
|
||||
return errors.New("empty backing file")
|
||||
}
|
||||
if cdrom {
|
||||
// CD-ROM media is always read only.
|
||||
ro = true
|
||||
}
|
||||
|
||||
// The target machine is the only writer of the data partition while the
|
||||
// emulated disk serves it, so /data goes read-only here before it can write
|
||||
// it. An image is served read-only instead, which leaves /data to this
|
||||
// side, so that handover happens after the gadget carries the image.
|
||||
if backing == partitionDevice {
|
||||
if err := SetDataWritable(false); err != nil {
|
||||
return fmt.Errorf("make %s read-only: %w", mediaDirectory, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := eject(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeLunAttr(lunROFlag, boolAttr(ro)); err != nil {
|
||||
return fmt.Errorf("set read-only flag: %w", err)
|
||||
}
|
||||
if err := writeLunAttr(lunCDRomFlag, boolAttr(cdrom)); err != nil {
|
||||
return fmt.Errorf("set cdrom flag: %w", err)
|
||||
}
|
||||
if err := writeLunAttr(lunInquiry, inquiryData(mediumProduct(backing, cdrom))); err != nil {
|
||||
return fmt.Errorf("set inquiry string: %w", err)
|
||||
}
|
||||
if err := writeLunAttr(usbProductAttr, mediumDeviceName(backing)); err != nil {
|
||||
// The target machine is left with the previous device name instead of
|
||||
// failing the mount: the medium itself is what matters.
|
||||
log.Warnf("set usb product failed: %s", err)
|
||||
}
|
||||
if err := writeLunAttr(lunMountDevice, backing); err != nil {
|
||||
if errors.Is(err, syscall.EBUSY) {
|
||||
return ErrMediumLocked
|
||||
}
|
||||
return fmt.Errorf("attach %s: %w", backing, err)
|
||||
}
|
||||
|
||||
if err := rebindUDC(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if backing != partitionDevice {
|
||||
if err := SetDataWritable(true); err != nil {
|
||||
log.Warnf("make %s writable failed: %s", mediaDirectory, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mediumProduct is the SCSI product string of the medium: an image is shown
|
||||
// under its file name, the mount mode names the medium otherwise.
|
||||
func mediumProduct(backing string, cdrom bool) string {
|
||||
if name := imageMediumName(backing); name != "" {
|
||||
return cutName(name, inquiryProductSize)
|
||||
}
|
||||
if cdrom {
|
||||
return "USB CD/DVD-ROM"
|
||||
}
|
||||
|
||||
return "USB Mass Storage"
|
||||
}
|
||||
|
||||
// mediumDeviceName is the USB product string, which the target machine shows
|
||||
// as the device name. The shared partition keeps the one it was built with.
|
||||
func mediumDeviceName(backing string) string {
|
||||
if name := imageMediumName(backing); name != "" {
|
||||
return cutName(name, usbProductSize)
|
||||
}
|
||||
|
||||
return usbProductName
|
||||
}
|
||||
|
||||
// imageMediumName is the name a mounted image is shown under: its file name
|
||||
// without the extension. The raw partition is not an image.
|
||||
func imageMediumName(backing string) string {
|
||||
if backing == partitionDevice {
|
||||
return ""
|
||||
}
|
||||
|
||||
return imageName(backing)
|
||||
}
|
||||
|
||||
// imageName strips the extension from the file name of a medium.
|
||||
func imageName(path string) string {
|
||||
name := filepath.Base(path)
|
||||
if extension := filepath.Ext(name); extension != "" {
|
||||
name = strings.TrimSuffix(name, extension)
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
// cutName cuts name to size bytes without leaving a broken UTF-8 rune behind:
|
||||
// the inquiry field and the USB product string are both byte limited.
|
||||
func cutName(name string, size int) string {
|
||||
if len(name) <= size {
|
||||
return name
|
||||
}
|
||||
|
||||
name = name[:size]
|
||||
for !utf8.ValidString(name) {
|
||||
name = name[:len(name)-1]
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
func writeLunAttr(path, value string) error {
|
||||
if err := os.WriteFile(path, []byte(value), 0o666); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolAttr(value bool) string {
|
||||
if value {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
func inquiryData(product string) string {
|
||||
return fmt.Sprintf("%-8s%-16s%04x", "NanoKVM", product, 0x0520)
|
||||
}
|
||||
|
||||
// unbindUDC tears the gadget down. The kernel clears the medium lock the
|
||||
// target machine set (prevent_medium_removal) whenever the function is
|
||||
// reconfigured, so this is what makes a forced eject possible: without it the
|
||||
// kernel refuses to touch the medium while the target machine is holding it.
|
||||
func unbindUDC() error {
|
||||
if err := writeLunAttr(gadgetUDC, "\n"); err != nil {
|
||||
return fmt.Errorf("unbind UDC: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// rebindUDC re-enumerates the gadget. It drops and re-adds every function,
|
||||
// keyboard and mouse included, which is why callers skip it when nothing
|
||||
// changed.
|
||||
func rebindUDC() error {
|
||||
// The forced path unbinds the gadget first, so it can already be unbound
|
||||
// here; the kernel answers that with ENODEV instead of doing anything.
|
||||
if err := writeLunAttr(gadgetUDC, "\n"); err != nil && !errors.Is(err, syscall.ENODEV) {
|
||||
return fmt.Errorf("unbind UDC: %w", err)
|
||||
}
|
||||
|
||||
udc, err := firstUDC()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if err := writeLunAttr(gadgetUDC, udc+"\n"); err != nil {
|
||||
return fmt.Errorf("bind UDC %s: %w", udc, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstUDC() (string, error) {
|
||||
entries, err := os.ReadDir(gadgetUDCDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list UDC: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
return entry.Name(), nil
|
||||
}
|
||||
return "", errors.New("no UDC available")
|
||||
}
|
||||
|
||||
// withHIDLock closes the HID devices around a gadget operation, because
|
||||
// re-enumerating drops them. The reopen is retried and never turns a
|
||||
// successful operation into a failure: builds without HID (/boot/disable_hid)
|
||||
// have nothing to open at all.
|
||||
func withHIDLock(fn func() error) error {
|
||||
h := hid.GetHid()
|
||||
h.Lock()
|
||||
h.CloseNoLock()
|
||||
defer func() {
|
||||
hid.ReopenAfterRebind()
|
||||
h.Unlock()
|
||||
}()
|
||||
|
||||
return fn()
|
||||
}
|
||||
@@ -2,56 +2,41 @@ package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/service/hid"
|
||||
"NanoKVM-Server/service/storage/diskfs"
|
||||
"NanoKVM-Server/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
imageDirectory = "/data"
|
||||
imageNone = "/dev/mmcblk0p3"
|
||||
cdromFlag = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/cdrom"
|
||||
mountDevice = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/file"
|
||||
inquiryString = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/inquiry_string"
|
||||
roFlag = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/ro"
|
||||
)
|
||||
// The handlers in this file are the pre-2.6 image API. The web UI uses the
|
||||
// media API instead, but these routes stay so scripts written against the
|
||||
// documented endpoints keep working: same paths, same request shapes, same
|
||||
// responses. They share the media implementation underneath, which is also
|
||||
// where the guards they used to lack now live.
|
||||
|
||||
func (s *Service) GetImages(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
var images []string
|
||||
|
||||
err := filepath.Walk(imageDirectory, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
name := strings.ToLower(info.Name())
|
||||
if strings.HasSuffix(name, ".iso") || strings.HasSuffix(name, ".img") {
|
||||
images = append(images, path)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
media, err := listMedia()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, "get images failed")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetImagesRsp{
|
||||
Files: images,
|
||||
})
|
||||
log.Debugf("get images success, total %d", len(images))
|
||||
files := make([]string, 0, len(media))
|
||||
for _, item := range media {
|
||||
if item.Type == proto.MediaTypeImage {
|
||||
files = append(files, filepath.Join(mediaDirectory, filepath.FromSlash(item.Rel)))
|
||||
}
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetImagesRsp{Files: files})
|
||||
log.Debugf("get images success, total %d", len(files))
|
||||
}
|
||||
|
||||
func (s *Service) MountImage(c *gin.Context) {
|
||||
@@ -59,88 +44,41 @@ func (s *Service) MountImage(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
// cdrom and ro flag
|
||||
// set to 0 when unmount image
|
||||
// set to 1 when mount image and the CD-ROM is enabled
|
||||
if req.File == "" || req.Cdrom {
|
||||
flag := "0"
|
||||
if req.File != "" && req.Cdrom {
|
||||
flag = "1"
|
||||
}
|
||||
|
||||
// unmount
|
||||
if err := os.WriteFile(mountDevice, []byte("\n"), 0o666); err != nil {
|
||||
log.Errorf("unmount file failed: %s", err)
|
||||
rsp.ErrRsp(c, -2, "unmount image failed")
|
||||
return
|
||||
}
|
||||
|
||||
// ro flag
|
||||
if err := os.WriteFile(roFlag, []byte(flag), 0o666); err != nil {
|
||||
log.Errorf("set ro flag failed: %s", err)
|
||||
rsp.ErrRsp(c, -2, "set ro flag failed")
|
||||
return
|
||||
}
|
||||
|
||||
// cdrom flag
|
||||
if err := os.WriteFile(cdromFlag, []byte(flag), 0o666); err != nil {
|
||||
log.Errorf("set cdrom flag failed: %s", err)
|
||||
rsp.ErrRsp(c, -2, "set cdrom flag failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
inquiryVen := "NanoKVM"
|
||||
inquiryPrd := "USB Mass Storage"
|
||||
inquiryVer := 0x0520
|
||||
if req.Cdrom {
|
||||
inquiryPrd = "USB CD/DVD-ROM"
|
||||
}
|
||||
inquiryData := fmt.Sprintf("%-8s%-16s%04x", inquiryVen, inquiryPrd, inquiryVer)
|
||||
|
||||
if err := os.WriteFile(inquiryString, []byte(inquiryData), 0o666); err != nil {
|
||||
log.Errorf("set inquiry %s failed: %s", inquiryData, err)
|
||||
rsp.ErrRsp(c, -2, "set inquiry failed")
|
||||
return
|
||||
}
|
||||
|
||||
// mount
|
||||
image := req.File
|
||||
if image == "" {
|
||||
image = imageNone
|
||||
}
|
||||
|
||||
if err := os.WriteFile(mountDevice, []byte(image), 0o666); err != nil {
|
||||
log.Errorf("mount file %s failed: %s", image, err)
|
||||
rsp.ErrRsp(c, -2, "mount image failed")
|
||||
return
|
||||
}
|
||||
|
||||
h := hid.GetHid()
|
||||
h.Lock()
|
||||
h.CloseNoLock()
|
||||
defer func() {
|
||||
h.OpenNoLock()
|
||||
h.Unlock()
|
||||
}()
|
||||
|
||||
// reset usb
|
||||
commands := []string{
|
||||
"echo > /sys/kernel/config/usb_gadget/g0/UDC",
|
||||
"ls /sys/class/udc/ | cat > /sys/kernel/config/usb_gadget/g0/UDC",
|
||||
}
|
||||
|
||||
for _, command := range commands {
|
||||
err := exec.Command("sh", "-c", command).Run()
|
||||
// An empty file keeps its original meaning: attach the whole data
|
||||
// partition, which is what the emulated disk exposes by default.
|
||||
backing := partitionDevice
|
||||
if req.File != "" {
|
||||
rel, err := legacyImageRel(req.File)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, "execute command failed")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
backing, err = utils.ResolveMediaID(rel)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.Cdrom {
|
||||
kind, err := diskfs.Detect(backing)
|
||||
if err != nil || !kind.Optical() {
|
||||
respondError(c, fmt.Errorf("%w: %s", errNotOptical, req.File))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// An image is always served read-only, the raw partition is not.
|
||||
ro := req.Cdrom || req.File != ""
|
||||
|
||||
if err := s.attachMedium(backing, req.Cdrom, ro, false); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
@@ -150,58 +88,37 @@ func (s *Service) MountImage(c *gin.Context) {
|
||||
func (s *Service) GetMountedImage(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
mode, err := hid.GetMode()
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, "get HID mode failed")
|
||||
rsp.ErrRsp(c, codeOperationFailed, "read failed")
|
||||
return
|
||||
}
|
||||
|
||||
if mode == hid.ModeHidOnly {
|
||||
rsp.OkRspWithData(c, &proto.GetMountedImageRsp{
|
||||
File: "",
|
||||
})
|
||||
return
|
||||
// The data partition is reported as "nothing mounted", the way this
|
||||
// endpoint has always answered.
|
||||
file := ""
|
||||
if status.State == diskStateImage {
|
||||
file = status.Backing
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(mountDevice)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, "read failed")
|
||||
return
|
||||
}
|
||||
|
||||
image := strings.ReplaceAll(string(content), "\n", "")
|
||||
if image == imageNone {
|
||||
image = ""
|
||||
}
|
||||
|
||||
data := &proto.GetMountedImageRsp{
|
||||
File: image,
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, data)
|
||||
rsp.OkRspWithData(c, &proto.GetMountedImageRsp{File: file})
|
||||
}
|
||||
|
||||
func (s *Service) GetCdRom(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
content, err := os.ReadFile(cdromFlag)
|
||||
cdrom, err := readLunBool(lunCDRomFlag)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, "read failed")
|
||||
rsp.ErrRsp(c, codeOperationFailed, "read failed")
|
||||
return
|
||||
}
|
||||
|
||||
flag := strings.ReplaceAll(string(content), "\n", "")
|
||||
flatInt, err := strconv.ParseInt(flag, 10, 64)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, "parse failed")
|
||||
return
|
||||
flag := int64(0)
|
||||
if cdrom {
|
||||
flag = 1
|
||||
}
|
||||
|
||||
data := &proto.GetCdRomRsp{
|
||||
Cdrom: flatInt,
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, data)
|
||||
rsp.OkRspWithData(c, &proto.GetCdRomRsp{Cdrom: flag})
|
||||
}
|
||||
|
||||
func (s *Service) DeleteImage(c *gin.Context) {
|
||||
@@ -209,25 +126,45 @@ func (s *Service) DeleteImage(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
filename := strings.ToLower(req.File)
|
||||
validPrefix := strings.HasPrefix(filename, imageDirectory)
|
||||
validSuffix := strings.HasSuffix(filename, ".iso") || strings.HasSuffix(filename, ".img")
|
||||
|
||||
if !validPrefix || !validSuffix {
|
||||
rsp.ErrRsp(c, -2, "invalid arguments")
|
||||
rel, err := legacyImageRel(req.File)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Remove(req.File); err != nil {
|
||||
rsp.ErrRsp(c, -3, "remove file failed")
|
||||
log.Errorf("failed to remove file %s: %s", req.File, err)
|
||||
target, err := utils.ResolveMediaID(rel)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := deleteMedium(target); err != nil {
|
||||
log.Errorf("failed to remove file %s: %s", target, err)
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("delete image %s success", req.File)
|
||||
}
|
||||
|
||||
// legacyImageRel turns the absolute path this API has always used into a
|
||||
// medium id. The old validation was a string prefix check, which also accepted
|
||||
// "/dataevil.img" and "/data/../../etc/passwd.iso"; resolving the path
|
||||
// properly closes both.
|
||||
func legacyImageRel(file string) (string, error) {
|
||||
if !filepath.IsAbs(file) {
|
||||
return "", fmt.Errorf("%w: %s is not an absolute path", utils.ErrInvalidMediaID, file)
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(mediaDirectory, filepath.Clean(file))
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("%w: %s is outside %s", utils.ErrInvalidMediaID, file, mediaDirectory)
|
||||
}
|
||||
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
42
server/service/storage/image_test.go
Normal file
42
server/service/storage/image_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestImageName(t *testing.T) {
|
||||
tests := []struct {
|
||||
file string
|
||||
want string
|
||||
}{
|
||||
{"/data/ubuntu.iso", "ubuntu"},
|
||||
{"/data/win11.img", "win11"},
|
||||
{"/data/a.b.c.iso", "a.b.c"},
|
||||
{"/data/no-extension", "no-extension"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := imageName(tt.file); got != tt.want {
|
||||
t.Errorf("imageName(%q) = %q, want %q", tt.file, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCutName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
size int
|
||||
want string
|
||||
}{
|
||||
{"ubuntu", 16, "ubuntu"},
|
||||
{"Install_macOS_27_Golden_Gate", 16, "Install_macOS_27"},
|
||||
{"Install_macOS_27_Golden_Gate", 126, "Install_macOS_27_Golden_Gate"},
|
||||
// 16 bytes is 5 three byte runes and 1 spare byte
|
||||
{"中文安装镜像文件系统", 16, "中文安装镜"},
|
||||
{"中文安装镜像文件系统", 126, "中文安装镜像文件系统"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := cutName(tt.name, tt.size); got != tt.want {
|
||||
t.Errorf("cutName(%q, %d) = %q, want %q", tt.name, tt.size, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
151
server/service/storage/job.go
Normal file
151
server/service/storage/job.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/utils"
|
||||
)
|
||||
|
||||
type jobState string
|
||||
|
||||
const (
|
||||
jobStateIdle jobState = "idle"
|
||||
jobStateInProgress jobState = "in_progress"
|
||||
jobStateSuccess jobState = "success"
|
||||
jobStateFailed jobState = "failed"
|
||||
)
|
||||
|
||||
// jobProgressInterval matches the ticker the downloader reports with, so the
|
||||
// web UI polls every transfer the same way.
|
||||
const jobProgressInterval = 2500 * time.Millisecond
|
||||
|
||||
// maxQueuedJobs bounds the queue: the transfers hold their HTTP connections
|
||||
// open while they wait, so an unbounded queue would let one client hoard them.
|
||||
const maxQueuedJobs = 8
|
||||
|
||||
var errTooManyJobs = errors.New("too many transfers are already queued")
|
||||
|
||||
// activeJobs counts the file manager transfers holding a job slot, across
|
||||
// every Service, so code outside the handlers can tell whether a transfer
|
||||
// temporary may still be written.
|
||||
var activeJobs atomic.Int32
|
||||
|
||||
// TransferInProgress reports whether any transfer that writes a temporary
|
||||
// file can be running: a file manager upload holds a job slot, the image
|
||||
// downloader and the updater hold the transfer lock file. A temporary found
|
||||
// while this is false was left behind by an interrupted transfer.
|
||||
func TransferInProgress() bool {
|
||||
return activeJobs.Load() > 0 || pathExists(utils.TransferSentinelPath)
|
||||
}
|
||||
|
||||
// activeJobTarget returns the path the running upload writes, if any.
|
||||
func (s *Service) activeJobTarget() (string, bool) {
|
||||
s.jobMutex.Lock()
|
||||
defer s.jobMutex.Unlock()
|
||||
|
||||
return s.jobName, s.jobDone != nil
|
||||
}
|
||||
|
||||
// beginJob claims the single media job slot, queueing behind whatever is
|
||||
// running instead of refusing: the user asked for a file to be written, and
|
||||
// waiting their turn is what they expect. The queue is first come, first
|
||||
// served, and a request whose client goes away leaves it.
|
||||
//
|
||||
// The transfer lock file that coordinates with the image downloader and the
|
||||
// application updater is deliberately not taken here. Writing into an image
|
||||
// only touches blocks that image already owns, so it neither races the
|
||||
// downloader nor holds it up.
|
||||
func (s *Service) beginJob(ctx context.Context, name string) (chan struct{}, error) {
|
||||
for {
|
||||
s.jobMutex.Lock()
|
||||
|
||||
if s.jobDone == nil {
|
||||
done := make(chan struct{})
|
||||
s.jobDone = done
|
||||
s.jobState = jobStateInProgress
|
||||
s.jobName = name
|
||||
s.jobLoaded = 0
|
||||
s.jobTotal = 0
|
||||
s.jobMessage = ""
|
||||
activeJobs.Add(1)
|
||||
s.jobMutex.Unlock()
|
||||
|
||||
return done, nil
|
||||
}
|
||||
|
||||
if len(s.jobWaiters) >= maxQueuedJobs {
|
||||
s.jobMutex.Unlock()
|
||||
return nil, errTooManyJobs
|
||||
}
|
||||
|
||||
waiter := make(chan struct{})
|
||||
s.jobWaiters = append(s.jobWaiters, waiter)
|
||||
s.jobMutex.Unlock()
|
||||
|
||||
select {
|
||||
case <-waiter:
|
||||
// Our turn; the loop tries to claim the slot.
|
||||
case <-ctx.Done():
|
||||
s.abandonWaiter(waiter)
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// abandonWaiter drops a waiter whose client went away. A waiter that is still
|
||||
// queued only has to leave the queue; one that was already handed the turn
|
||||
// (finishJob closed its channel) has to pass it on, or the transfers waiting
|
||||
// behind it would sit there until their own clients gave up too.
|
||||
func (s *Service) abandonWaiter(waiter chan struct{}) {
|
||||
s.jobMutex.Lock()
|
||||
defer s.jobMutex.Unlock()
|
||||
|
||||
for i, queued := range s.jobWaiters {
|
||||
if queued == waiter {
|
||||
s.jobWaiters = append(s.jobWaiters[:i], s.jobWaiters[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if s.jobDone == nil && len(s.jobWaiters) > 0 {
|
||||
close(s.jobWaiters[0])
|
||||
s.jobWaiters = s.jobWaiters[1:]
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) setJobProgress(done chan struct{}, loaded, total int64) {
|
||||
s.jobMutex.Lock()
|
||||
defer s.jobMutex.Unlock()
|
||||
|
||||
if s.jobDone == done {
|
||||
s.jobLoaded = loaded
|
||||
s.jobTotal = total
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) finishJob(done chan struct{}, state jobState, message string) {
|
||||
s.jobMutex.Lock()
|
||||
if s.jobDone != done {
|
||||
s.jobMutex.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
s.jobDone = nil
|
||||
activeJobs.Add(-1)
|
||||
s.jobState = state
|
||||
s.jobLoaded = 0
|
||||
s.jobTotal = 0
|
||||
s.jobMessage = message
|
||||
|
||||
// The slot goes to whoever queued first.
|
||||
if len(s.jobWaiters) > 0 {
|
||||
close(s.jobWaiters[0])
|
||||
s.jobWaiters = s.jobWaiters[1:]
|
||||
}
|
||||
s.jobMutex.Unlock()
|
||||
|
||||
close(done)
|
||||
}
|
||||
880
server/service/storage/media.go
Normal file
880
server/service/storage/media.go
Normal file
@@ -0,0 +1,880 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/service/storage/diskfs"
|
||||
"NanoKVM-Server/utils"
|
||||
)
|
||||
|
||||
// Error codes the web UI reacts to. The response envelope is shared with every
|
||||
// other endpoint, so these stay inside the media API's own range: the UI needs
|
||||
// to tell "the target machine is still holding the medium" apart from "you
|
||||
// have to enable the virtual disk first", and neither apart from a plain
|
||||
// failure.
|
||||
const (
|
||||
codeInvalidArgument = -1
|
||||
codeOperationFailed = -2
|
||||
codeStorageDisabled = -10
|
||||
codeMediumLocked = -11
|
||||
codeMediumBusy = -12
|
||||
codeNotBrowsable = -13
|
||||
codeInsufficientSpace = -14
|
||||
codeNotOptical = -15
|
||||
codeAlreadyExists = -16
|
||||
)
|
||||
|
||||
// partitionDevice is the whole data partition. It is what the emulated disk
|
||||
// exposes by default, and while it is attached every medium is at the mercy of
|
||||
// the target machine, because every medium lives on it.
|
||||
const partitionDevice = "/dev/mmcblk0p3"
|
||||
|
||||
// mediaDirectory holds the media. It is the same directory the virtual disk
|
||||
// hands to the target machine, so nothing secret belongs in it.
|
||||
const mediaDirectory = utils.MediaDirectory
|
||||
|
||||
// mediumOperationMu serializes the two things that must not interleave: the
|
||||
// file manager opening a medium and the gadget serving one.
|
||||
var mediumOperationMu sync.Mutex
|
||||
|
||||
// partitionWriteRefs counts long-running writes that land directly on the
|
||||
// data partition. The counter alone is atomic because a handle releases it
|
||||
// after openMedium has returned; taking the reference still happens under
|
||||
// mediumOperationMu, together with the gadget-state check, so a mount cannot
|
||||
// slip between the two.
|
||||
var partitionWriteRefs atomic.Int64
|
||||
|
||||
// LockVirtualDiskTransition serializes a gadget transition with the media API.
|
||||
// It applies to disk and network changes because both restart the gadget and
|
||||
// can make the boot script expose the raw partition. The returned function
|
||||
// releases the lock and must be deferred by the caller. A transition is refused
|
||||
// while the file manager holds an image: exposing the raw partition would
|
||||
// otherwise let the target machine overwrite the image underneath its loop
|
||||
// device.
|
||||
func LockVirtualDiskTransition() (func(), error) {
|
||||
mediumOperationMu.Lock()
|
||||
if err := ensureNoOpenImages(); err != nil {
|
||||
mediumOperationMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
if partitionWriteRefs.Load() > 0 {
|
||||
mediumOperationMu.Unlock()
|
||||
return nil, errMediumBusy
|
||||
}
|
||||
|
||||
return mediumOperationMu.Unlock, nil
|
||||
}
|
||||
|
||||
func ensureNoOpenImages() error {
|
||||
if len(diskfs.MountedImages()) > 0 {
|
||||
return errMediumBusy
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
errStorageDisabled = errors.New("the virtual disk is disabled on this device")
|
||||
errPartitionOwned = errors.New("the data partition is attached to the target machine")
|
||||
errMediumAttached = errors.New("the medium is attached to the target machine")
|
||||
errMediumBusy = errors.New("the medium is in use by the file manager")
|
||||
errNoSpace = errors.New("not enough space on the data partition")
|
||||
errNotOptical = errors.New("a CD-ROM can only carry an ISO 9660 or UDF image")
|
||||
errNotAMedium = errors.New("the data partition has no file manager of its own")
|
||||
)
|
||||
|
||||
func (s *Service) GetMedia(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
media, err := listMedia()
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
caps := diskfs.Probe()
|
||||
rsp.OkRspWithData(c, &proto.GetMediaRsp{
|
||||
Media: media,
|
||||
Caps: proto.MediaCaps{Loop: caps.Loop},
|
||||
})
|
||||
|
||||
log.Debugf("get media success, total %d", len(media))
|
||||
}
|
||||
|
||||
func (s *Service) GetMediaState(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
// The UI polls this, so a /data left read-only by a failed release is put
|
||||
// back here rather than waiting for the user to reboot.
|
||||
if err := healDataPartition(); err != nil {
|
||||
logHealFailure(err)
|
||||
}
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// With the virtual disk off the LUN may not exist at all, and that state
|
||||
// is exactly what the web UI needs to see to offer turning it on.
|
||||
cdrom := false
|
||||
if status.State != diskStateUnsupported {
|
||||
cdrom, err = readLunBool(lunCDRomFlag)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
state := &proto.MediaStateRsp{
|
||||
State: string(status.State),
|
||||
HidOnly: status.HidOnly,
|
||||
Open: openMediaIDs(),
|
||||
Cdrom: cdrom,
|
||||
LocalWritable: DataWritable(),
|
||||
}
|
||||
|
||||
switch status.State {
|
||||
case diskStateImage:
|
||||
state.Type = proto.MediaTypeImage
|
||||
state.Rel = mediaRel(status.Backing)
|
||||
case diskStatePartition:
|
||||
state.Type = proto.MediaTypePartition
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, state)
|
||||
}
|
||||
|
||||
func (s *Service) GetMediaStatus(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
s.jobMutex.Lock()
|
||||
data := &proto.MediaStatusRsp{
|
||||
Status: string(s.jobState),
|
||||
Name: s.jobName,
|
||||
Percentage: utils.FormatPercentage(s.jobLoaded, s.jobTotal),
|
||||
Loaded: s.jobLoaded,
|
||||
Total: s.jobTotal,
|
||||
Message: s.jobMessage,
|
||||
}
|
||||
s.jobMutex.Unlock()
|
||||
|
||||
rsp.OkRspWithData(c, data)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteMedia(c *gin.Context) {
|
||||
var req proto.MediaRefReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
|
||||
return
|
||||
}
|
||||
if req.Type == proto.MediaTypePartition {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "the data partition cannot be deleted")
|
||||
return
|
||||
}
|
||||
|
||||
backing, err := utils.ResolveMediaID(req.Rel)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := deleteMedium(backing); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("delete medium %s success", req.Rel)
|
||||
}
|
||||
|
||||
// deleteMedium keeps the state checks and the unlink in the same critical
|
||||
// section as gadget attachment. Otherwise a concurrent mount can attach the
|
||||
// file after the check and leave the gadget reading a deleted inode.
|
||||
func deleteMedium(backing string) error {
|
||||
mediumOperationMu.Lock()
|
||||
defer mediumOperationMu.Unlock()
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status.State == diskStateImage && status.Backing == backing {
|
||||
return errMediumAttached
|
||||
}
|
||||
if status.State == diskStatePartition {
|
||||
return errPartitionOwned
|
||||
}
|
||||
if diskfs.IsMounted(backing) {
|
||||
return errMediumBusy
|
||||
}
|
||||
|
||||
if err := os.Remove(backing); err != nil {
|
||||
return fmt.Errorf("remove %s: %w", backing, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) MountMedia(c *gin.Context) {
|
||||
var req proto.MountMediaReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
backing, err := resolveBacking(req.Type, req.Rel)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
if status.State == diskStateUnsupported {
|
||||
respondError(c, errStorageDisabled)
|
||||
return
|
||||
}
|
||||
if diskfs.IsMounted(backing) {
|
||||
respondError(c, errMediumBusy)
|
||||
return
|
||||
}
|
||||
|
||||
// A CD-ROM is an optical disc, so its medium has to hold a disc
|
||||
// filesystem (ISO 9660 or UDF): the gadget then reports 2048 byte sectors,
|
||||
// and a partition or any other filesystem read that way is what the target
|
||||
// machine reports as a corrupt disc.
|
||||
if req.Cdrom {
|
||||
kind, err := diskfs.Detect(backing)
|
||||
if err != nil || !kind.Optical() {
|
||||
respondError(c, fmt.Errorf("%w: %s", errNotOptical, req.Rel))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// An image is always served read-only: the target machine must not write the
|
||||
// file this side browses, and a read-only medium is what lets this side keep
|
||||
// writing /data while it is attached.
|
||||
ro := req.Type != proto.MediaTypePartition || req.Cdrom
|
||||
if err := s.attachMedium(backing, req.Cdrom, ro, req.Force); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("mount medium %s (%s) success, cdrom=%v ro=%v", backing, req.Type, req.Cdrom, ro)
|
||||
}
|
||||
|
||||
func (s *Service) EjectMedia(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
// The body is optional: without it this is an ordinary eject, which fails
|
||||
// when the target machine holds the medium, and the caller retries with
|
||||
// force after asking the user.
|
||||
var req proto.EjectMediaReq
|
||||
_ = proto.ParseFormRequest(c, &req)
|
||||
|
||||
if err := s.detachMedium(req.Force); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("eject medium success")
|
||||
}
|
||||
|
||||
// attachMedium is the whole mount path: refuse a relabeling that changes
|
||||
// nothing, then run the kernel's required sequence with the HID devices closed.
|
||||
func (s *Service) attachMedium(backing string, cdrom, ro, force bool) error {
|
||||
// Mounting here and serving the medium to the target machine are two sides
|
||||
// of one decision: without the lock, an upload into an image can be opening
|
||||
// it read-write while the gadget starts serving the same file.
|
||||
mediumOperationMu.Lock()
|
||||
defer mediumOperationMu.Unlock()
|
||||
|
||||
if backing == partitionDevice {
|
||||
// Every image lives on the partition. Handing that partition to the
|
||||
// target while any image has a loop mount would give both sides the
|
||||
// same blocks, even though the partition itself is not in mounts. A
|
||||
// direct partition write has no loop mount to check, so it carries an
|
||||
// explicit reference instead.
|
||||
if err := ensureNoOpenImages(); err != nil {
|
||||
return err
|
||||
}
|
||||
if partitionWriteRefs.Load() > 0 {
|
||||
return errMediumBusy
|
||||
}
|
||||
} else if diskfs.IsMounted(backing) {
|
||||
return errMediumBusy
|
||||
}
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Attaching what the target machine already has is a no-op: running the
|
||||
// sequence again would remove the medium and re-enumerate the gadget under
|
||||
// a machine that is using it - and fail with EBUSY if it holds the disk.
|
||||
if status.Backing != "" && status.Backing == backing {
|
||||
config, err := readLunConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.cdrom == cdrom && config.ro == ro {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
err = withHIDLock(func() error {
|
||||
err := attach(backing, cdrom, ro)
|
||||
if errors.Is(err, ErrMediumLocked) && force {
|
||||
// The target machine holds the medium; re-enumerating the gadget
|
||||
// is the only way to take it back without its cooperation.
|
||||
if err := unbindUDC(); err != nil {
|
||||
return err
|
||||
}
|
||||
err = attach(backing, cdrom, ro)
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
// attach may have changed /data before a later step failed. Base the
|
||||
// rollback on the state that was actually left behind, not on the
|
||||
// state in which the transition started.
|
||||
if restoreErr := restoreDataAfterFailedAttach(); restoreErr != nil {
|
||||
return errors.Join(err, restoreErr)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func restoreDataAfterFailedAttach() error {
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read medium state after failed mount: %w", err)
|
||||
}
|
||||
if status.State == diskStatePartition {
|
||||
return nil
|
||||
}
|
||||
if err := SetDataWritable(true); err != nil {
|
||||
return fmt.Errorf("restore %s writable after failed mount: %w", mediaDirectory, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) detachMedium(force bool) error {
|
||||
mediumOperationMu.Lock()
|
||||
defer mediumOperationMu.Unlock()
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status.State == diskStateUnsupported {
|
||||
return errStorageDisabled
|
||||
}
|
||||
if status.State == diskStateNone {
|
||||
// Nothing is attached, so there is nothing to release and nothing to
|
||||
// re-enumerate.
|
||||
return nil
|
||||
}
|
||||
|
||||
release := func() error {
|
||||
if err := eject(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Nothing is served any more, so the data partition belongs to this
|
||||
// side again.
|
||||
releaseDataPartition()
|
||||
return rebindUDC()
|
||||
}
|
||||
|
||||
err = withHIDLock(release)
|
||||
if errors.Is(err, ErrMediumLocked) && force {
|
||||
err = withHIDLock(func() error {
|
||||
if err := unbindUDC(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The lock went away with the re-enumeration, so the medium can be
|
||||
// detached now. The gadget is bound again either way: leaving it
|
||||
// unbound would cost the target machine its keyboard, mouse and
|
||||
// disk until something else rebinds it.
|
||||
detachErr := eject()
|
||||
releaseDataPartition()
|
||||
bindErr := rebindUDC()
|
||||
if detachErr != nil {
|
||||
return detachErr
|
||||
}
|
||||
return bindErr
|
||||
})
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) DownloadMedia(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
rel := c.Query("rel")
|
||||
if rel == "" || c.Query("type") == proto.MediaTypePartition {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "a disk image is required")
|
||||
return
|
||||
}
|
||||
|
||||
backing, err := utils.ResolveMediaID(rel)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// Downloading is reading, and a read-only data partition is still readable:
|
||||
// the target machine owning the partition does not stop this side from
|
||||
// streaming an image file out of it. Its contents may change while it is
|
||||
// being served, which is the same caveat the file browser has.
|
||||
if status.State == diskStateImage && status.Backing == backing {
|
||||
respondError(c, errMediumAttached)
|
||||
return
|
||||
}
|
||||
// A file manager write may be in flight; streaming the image now would
|
||||
// hand out a file that changes underneath the download. The pin refuses a
|
||||
// writable mount for the whole stream, so the check cannot go stale between
|
||||
// here and the open the way a one-shot IsMounted lookup did.
|
||||
releasePin, err := diskfs.AcquireReadPin(backing)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
defer releasePin()
|
||||
|
||||
file, err := os.Open(backing)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Disposition", attachmentHeader(filepath.Base(backing)))
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), file)
|
||||
}
|
||||
|
||||
// listMedia returns every image in the media directory plus the data partition
|
||||
// itself, which stays listed because exposing it over the emulated disk is the
|
||||
// documented way to drop an image onto the device.
|
||||
func listMedia() ([]proto.MediaItem, error) {
|
||||
// Sizes and names under /data can be written by the target machine at any
|
||||
// time; make the walk see them.
|
||||
invalidatePartitionCache()
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
images, err := findImages()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
media := make([]proto.MediaItem, 0, len(images)+1)
|
||||
for _, path := range images {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
kind, _ := diskfs.Detect(path)
|
||||
media = append(media, proto.MediaItem{
|
||||
Type: proto.MediaTypeImage,
|
||||
Rel: mediaRel(path),
|
||||
Name: filepath.Base(path),
|
||||
Size: uint64(info.Size()),
|
||||
FS: string(kind),
|
||||
Browsable: kind.Browsable(),
|
||||
Attached: status.Backing == path,
|
||||
Modified: info.ModTime(),
|
||||
})
|
||||
}
|
||||
|
||||
// The partition is what the emulated disk exposes by default, so it is the
|
||||
// medium a user most often has something to fetch from.
|
||||
media = append(media, proto.MediaItem{
|
||||
Type: proto.MediaTypePartition,
|
||||
Name: "Shared folder",
|
||||
FS: "exfat",
|
||||
Browsable: true,
|
||||
Attached: status.State == diskStatePartition,
|
||||
})
|
||||
|
||||
return media, nil
|
||||
}
|
||||
|
||||
// findImages lists what the web UI offers to mount: every .iso and .img on the
|
||||
// volume, wherever it was put - uploaded here, copied over by the target
|
||||
// machine, or left by an older version of this feature.
|
||||
func findImages() ([]string, error) {
|
||||
var images []string
|
||||
|
||||
err := filepath.Walk(mediaDirectory, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
// A device whose data partition was never created has no media,
|
||||
// which is not worth failing the whole listing over.
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && utils.IsMediaFile(info.Name()) {
|
||||
images = append(images, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list media: %w", err)
|
||||
}
|
||||
|
||||
sort.Strings(images)
|
||||
return images, nil
|
||||
}
|
||||
|
||||
// resolveBacking maps a request's medium id to the file the gadget would use.
|
||||
func resolveBacking(mediaType, rel string) (string, error) {
|
||||
if mediaType == proto.MediaTypePartition {
|
||||
return partitionDevice, nil
|
||||
}
|
||||
return utils.ResolveMediaID(rel)
|
||||
}
|
||||
|
||||
// mediaRel is the inverse of ResolveMediaID, for reporting a backing file back
|
||||
// to the web UI.
|
||||
func mediaRel(backing string) string {
|
||||
return strings.TrimPrefix(backing, mediaDirectory+"/")
|
||||
}
|
||||
|
||||
func openMediaIDs() []string {
|
||||
mounted := diskfs.MountedImages()
|
||||
ids := make([]string, 0, len(mounted))
|
||||
for _, path := range mounted {
|
||||
ids = append(ids, mediaRel(path))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// InstallImage atomically replaces destPath with a downloaded or uploaded
|
||||
// image. The same lock used by gadget and file-manager operations covers both
|
||||
// the checks and the rename, so the destination cannot become attached or
|
||||
// loop-mounted in between.
|
||||
func InstallImage(tempPath, destPath string) error {
|
||||
mediumOperationMu.Lock()
|
||||
defer mediumOperationMu.Unlock()
|
||||
|
||||
canonicalDest, err := utils.CanonicalMediaPath(destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
destPath = canonicalDest
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return installImage(tempPath, destPath, status, diskfs.IsMounted(destPath))
|
||||
}
|
||||
|
||||
func installImage(tempPath, destPath string, status diskStatus, mounted bool) error {
|
||||
if status.State == diskStatePartition {
|
||||
return ErrDataPartitionInUse
|
||||
}
|
||||
if status.State == diskStateImage && status.Backing == destPath {
|
||||
return fmt.Errorf("%s is attached to the target machine; eject it there first", filepath.Base(destPath))
|
||||
}
|
||||
if mounted {
|
||||
return fmt.Errorf("%w: %s", errMediumBusy, filepath.Base(destPath))
|
||||
}
|
||||
|
||||
if err := os.Rename(tempPath, destPath); err != nil {
|
||||
return fmt.Errorf("install image %s: %w", filepath.Base(destPath), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openMedium returns a handle for a medium that is safe to touch. Everything
|
||||
// that reads or writes a medium's filesystem goes through here, so the two
|
||||
// ways to corrupt an image - writing it while the target machine owns it, and
|
||||
// writing it while the whole data partition is attached - are refused in one
|
||||
// place.
|
||||
func openMedium(mediaType, rel string, writable bool) (diskfs.Image, error) {
|
||||
// The check and the mount are one step: the gadget must not start serving a
|
||||
// medium between the two, or this side ends up writing a filesystem the
|
||||
// target machine is reading.
|
||||
mediumOperationMu.Lock()
|
||||
defer mediumOperationMu.Unlock()
|
||||
|
||||
return openMediumLocked(mediaType, rel, writable)
|
||||
}
|
||||
|
||||
// openMediumLocked is openMedium for a caller that already holds
|
||||
// mediumOperationMu. Keeping the lock until a subsequent operation finishes
|
||||
// is necessary when a check alone does not protect that operation, as with a
|
||||
// file delete through the shared partition.
|
||||
func openMediumLocked(mediaType, rel string, writable bool) (diskfs.Image, error) {
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A disabled virtual disk (diskStateUnsupported) is not refused here: with
|
||||
// nothing shared, the data partition is this side's alone and can be
|
||||
// browsed as usual, and an image needs no gadget to be mounted here.
|
||||
//
|
||||
// While the data partition is attached, the target machine owns every
|
||||
// medium - they all live on it - so nothing from here may write into one:
|
||||
// the two sides cache the exFAT allocation tables independently, and a
|
||||
// write from this side can hand the target machine clusters it is already
|
||||
// using. Reading is fine and needs no such care.
|
||||
if writable && status.State == diskStatePartition {
|
||||
return nil, errPartitionOwned
|
||||
}
|
||||
// Nothing owns the partition, so a read-only /data here is a leftover the
|
||||
// mount did not clear. Writing through it would fail with EROFS.
|
||||
if writable {
|
||||
if err := SetDataWritable(true); err != nil {
|
||||
return nil, fmt.Errorf("make %s writable: %w", mediaDirectory, err)
|
||||
}
|
||||
}
|
||||
|
||||
if mediaType == proto.MediaTypePartition {
|
||||
// The partition needs no loop device: the emulated disk already hands
|
||||
// this very directory to the target machine. A writable handle has to
|
||||
// outlive this mutex: without the reference, a mount could take the
|
||||
// partition away after open and interrupt the write it returned for.
|
||||
image, err := diskfs.OpenDirectory(mediaDirectory)
|
||||
if err != nil || !writable {
|
||||
return image, err
|
||||
}
|
||||
partitionWriteRefs.Add(1)
|
||||
return &dataPartitionWriteHandle{Image: image}, nil
|
||||
}
|
||||
|
||||
backing, err := utils.ResolveMediaID(rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status.State == diskStateImage && status.Backing == backing {
|
||||
return nil, errMediumAttached
|
||||
}
|
||||
|
||||
return diskfs.Mount(backing, writable)
|
||||
}
|
||||
|
||||
// dataPartitionWriteHandle keeps the raw partition marked busy until the
|
||||
// write that opened it has finished and closed the handle.
|
||||
type dataPartitionWriteHandle struct {
|
||||
diskfs.Image
|
||||
released atomic.Bool
|
||||
}
|
||||
|
||||
func (h *dataPartitionWriteHandle) Close() error {
|
||||
err := h.Image.Close()
|
||||
if h.released.CompareAndSwap(false, true) {
|
||||
partitionWriteRefs.Add(-1)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// BeginDataPartitionWrite reserves the data partition for a transfer that does
|
||||
// not go through a medium handle, such as writing a whole image download. The
|
||||
// reference is taken while the state is checked, then held until release runs;
|
||||
// handing the raw partition to the target machine is refused in between.
|
||||
func BeginDataPartitionWrite() (func(), error) {
|
||||
mediumOperationMu.Lock()
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
mediumOperationMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
if status.State == diskStatePartition {
|
||||
mediumOperationMu.Unlock()
|
||||
return nil, ErrDataPartitionInUse
|
||||
}
|
||||
if err := SetDataWritable(true); err != nil {
|
||||
mediumOperationMu.Unlock()
|
||||
return nil, fmt.Errorf("make %s writable: %w", mediaDirectory, err)
|
||||
}
|
||||
partitionWriteRefs.Add(1)
|
||||
mediumOperationMu.Unlock()
|
||||
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
partitionWriteRefs.Add(-1)
|
||||
})
|
||||
}, nil
|
||||
}
|
||||
|
||||
// releaseDataPartition hands /data back to this side: it is read-only while the
|
||||
// emulated disk serves the raw partition, and writable again once no medium or
|
||||
// a read-only image is attached.
|
||||
func releaseDataPartition() {
|
||||
// SetDataWritable drops the target-owned cache before remounting read-write.
|
||||
if err := SetDataWritable(true); err != nil {
|
||||
log.Warnf("make %s writable failed: %s", mediaDirectory, err)
|
||||
}
|
||||
}
|
||||
|
||||
// attachmentHeader builds the Content-Disposition of a download the way
|
||||
// browsers want it: an ASCII fallback for everything old, plus the real name
|
||||
// percent-encoded, because a file name is not necessarily ASCII and %q would
|
||||
// send a Go-quoted string instead of a name.
|
||||
func attachmentHeader(name string) string {
|
||||
fallback := strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r > 0x7e || r == '"' || r == '\\' {
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
|
||||
return fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", fallback, rfc5987Encode(name))
|
||||
}
|
||||
|
||||
// rfc5987Encode percent-encodes every byte that is not an RFC 5987 attr-char.
|
||||
// url.PathEscape is not that set: it leaves "=", ":", "@" and friends alone,
|
||||
// which a strict parser reads as the end of the value.
|
||||
func rfc5987Encode(value string) string {
|
||||
const hex = "0123456789ABCDEF"
|
||||
|
||||
var encoded strings.Builder
|
||||
for i := 0; i < len(value); i++ {
|
||||
b := value[i]
|
||||
if isAttrChar(b) {
|
||||
encoded.WriteByte(b)
|
||||
continue
|
||||
}
|
||||
encoded.WriteByte('%')
|
||||
encoded.WriteByte(hex[b>>4])
|
||||
encoded.WriteByte(hex[b&0x0f])
|
||||
}
|
||||
return encoded.String()
|
||||
}
|
||||
|
||||
func isAttrChar(b byte) bool {
|
||||
switch {
|
||||
case b >= 'A' && b <= 'Z', b >= 'a' && b <= 'z', b >= '0' && b <= '9':
|
||||
return true
|
||||
}
|
||||
return strings.IndexByte("!#$&+-.^_`|~", b) >= 0
|
||||
}
|
||||
|
||||
// ErrDataPartitionInUse reports a write that would land on the data partition
|
||||
// while the target machine owns it.
|
||||
var ErrDataPartitionInUse = errors.New("the data partition is attached to the target machine; eject it first")
|
||||
|
||||
// EnsureDataPartitionWritable performs the one-shot form of the partition
|
||||
// ownership check. Anything that keeps writing after the check has to hold
|
||||
// BeginDataPartitionWrite instead, or the partition can be mounted in between.
|
||||
func EnsureDataPartitionWritable() error {
|
||||
release, err := BeginDataPartitionWrite()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
release()
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteGuard decides whether removing target, the canonical path of a file
|
||||
// or directory, would take away a medium that must not vanish: the one the
|
||||
// target machine is served (backing) or one the file manager keeps mounted. A
|
||||
// directory delete is recursive, so a medium anywhere below target counts.
|
||||
func deleteGuard(target, backing string, mounted []string) error {
|
||||
if backing != "" && pathWithin(backing, target) {
|
||||
return errMediumAttached
|
||||
}
|
||||
for _, path := range mounted {
|
||||
if pathWithin(path, target) {
|
||||
return errMediumBusy
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pathWithin reports whether path is dir or lies below it. Both sides are
|
||||
// compared upper-cased, the way exFAT matches names, so a spelling that only
|
||||
// differs in case cannot slip past a check that the canonical lookup missed.
|
||||
func pathWithin(path, dir string) bool {
|
||||
path = strings.ToUpper(path)
|
||||
dir = strings.ToUpper(strings.TrimSuffix(dir, "/"))
|
||||
return path == dir || strings.HasPrefix(path, dir+"/")
|
||||
}
|
||||
|
||||
// IsAttached reports whether the emulated disk is currently backed by path.
|
||||
// Callers that replace a file in place have to ask first: the gadget keeps
|
||||
// using the inode it opened, so renaming over it leaves the target machine
|
||||
// reading a file the rest of the system can no longer see.
|
||||
func IsAttached(path string) bool {
|
||||
if canonical, err := utils.CanonicalMediaPath(path); err == nil {
|
||||
path = canonical
|
||||
}
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return status.Backing != "" && status.Backing == path
|
||||
}
|
||||
|
||||
func respondError(c *gin.Context, err error) {
|
||||
var rsp proto.Response
|
||||
|
||||
switch {
|
||||
case errors.Is(err, utils.ErrInvalidMediaID), errors.Is(err, utils.ErrInvalidMediaPath):
|
||||
rsp.ErrRsp(c, codeInvalidArgument, err.Error())
|
||||
case errors.Is(err, errStorageDisabled):
|
||||
rsp.ErrRsp(c, codeStorageDisabled, err.Error())
|
||||
case errors.Is(err, ErrMediumLocked):
|
||||
rsp.ErrRsp(c, codeMediumLocked, err.Error())
|
||||
case errors.Is(err, errPartitionOwned), errors.Is(err, errMediumAttached),
|
||||
errors.Is(err, errMediumBusy), errors.Is(err, diskfs.ErrBusy):
|
||||
rsp.ErrRsp(c, codeMediumBusy, err.Error())
|
||||
case errors.Is(err, diskfs.ErrUnsupported), errors.Is(err, errNotAMedium):
|
||||
rsp.ErrRsp(c, codeNotBrowsable, err.Error())
|
||||
case errors.Is(err, errNoSpace), errors.Is(err, errTooManyJobs):
|
||||
rsp.ErrRsp(c, codeInsufficientSpace, err.Error())
|
||||
case errors.Is(err, errNotOptical):
|
||||
rsp.ErrRsp(c, codeNotOptical, err.Error())
|
||||
default:
|
||||
rsp.ErrRsp(c, codeOperationFailed, err.Error())
|
||||
}
|
||||
}
|
||||
187
server/service/storage/media_test.go
Normal file
187
server/service/storage/media_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeleteGuard(t *testing.T) {
|
||||
const attached = "/data/images/win.iso"
|
||||
mounted := []string{"/data/share/disk.img"}
|
||||
|
||||
tests := []struct {
|
||||
target string
|
||||
want error
|
||||
}{
|
||||
{"/data/images/win.iso", errMediumAttached},
|
||||
{"/data/IMAGES/WIN.ISO", errMediumAttached},
|
||||
{"/data/images", errMediumAttached},
|
||||
{"/data/IMAGES", errMediumAttached},
|
||||
{"/data/Images/", errMediumAttached},
|
||||
{"/data/share/disk.img", errMediumBusy},
|
||||
{"/data/Share", errMediumBusy},
|
||||
{"/data/images/win.iso.bak", nil},
|
||||
{"/data/imagesold", nil},
|
||||
{"/data/share/other.img", nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := deleteGuard(tt.target, attached, mounted); !errors.Is(got, tt.want) || (got == nil) != (tt.want == nil) {
|
||||
t.Errorf("deleteGuard(%q) = %v, want %v", tt.target, got, tt.want)
|
||||
}
|
||||
}
|
||||
|
||||
if err := deleteGuard("/data/images", "", nil); err != nil {
|
||||
t.Errorf("deleteGuard with nothing attached = %v, want nil", err)
|
||||
}
|
||||
// The raw partition is served by device path, which no file under /data
|
||||
// can match.
|
||||
if err := deleteGuard("/data/images", partitionDevice, nil); err != nil {
|
||||
t.Errorf("deleteGuard with the partition attached = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathWithinRelative(t *testing.T) {
|
||||
// The running upload's target is medium relative, like a delete request.
|
||||
if !pathWithin("dir/sub/file.bin", "dir") || !pathWithin("DIR/file.bin", "dir") {
|
||||
t.Error("an upload below the directory was not matched")
|
||||
}
|
||||
if pathWithin("dir2/file.bin", "dir") {
|
||||
t.Error("a sibling with a common prefix was matched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbandonWaiterHandsTurnOn(t *testing.T) {
|
||||
s := &Service{jobState: jobStateIdle}
|
||||
|
||||
done, err := s.beginJob(context.Background(), "a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
first := make(chan struct{})
|
||||
second := make(chan struct{})
|
||||
s.jobWaiters = []chan struct{}{first, second}
|
||||
|
||||
// The slot goes to the first waiter, whose client is already gone.
|
||||
s.finishJob(done, jobStateSuccess, "")
|
||||
assertClosed(t, first, "first waiter")
|
||||
assertOpen(t, second, "second waiter before the abandon")
|
||||
|
||||
s.abandonWaiter(first)
|
||||
assertClosed(t, second, "second waiter")
|
||||
if len(s.jobWaiters) != 0 {
|
||||
t.Errorf("queue holds %d waiters, want 0", len(s.jobWaiters))
|
||||
}
|
||||
if activeJobs.Load() != 0 {
|
||||
t.Errorf("activeJobs = %d, want 0", activeJobs.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbandonQueuedWaiterLeavesOthers(t *testing.T) {
|
||||
s := &Service{jobState: jobStateIdle}
|
||||
|
||||
done, err := s.beginJob(context.Background(), "a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.finishJob(done, jobStateSuccess, "")
|
||||
|
||||
first := make(chan struct{})
|
||||
second := make(chan struct{})
|
||||
s.jobWaiters = []chan struct{}{first, second}
|
||||
|
||||
s.abandonWaiter(first)
|
||||
assertOpen(t, second, "second waiter")
|
||||
if len(s.jobWaiters) != 1 || s.jobWaiters[0] != second {
|
||||
t.Errorf("queue = %v, want only the second waiter", s.jobWaiters)
|
||||
}
|
||||
}
|
||||
|
||||
func assertClosed(t *testing.T, ch chan struct{}, what string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
default:
|
||||
t.Errorf("%s was not woken", what)
|
||||
}
|
||||
}
|
||||
|
||||
func assertOpen(t *testing.T, ch chan struct{}, what string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
t.Errorf("%s was woken", what)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropCachesValues(t *testing.T) {
|
||||
// The 5.10 sysctl refuses anything outside 1..4.
|
||||
for _, value := range []string{dropCachesQuiet, dropCachesDentry, dropCachesAllData} {
|
||||
if len(value) != 1 || value[0] < '1' || value[0] > '4' {
|
||||
t.Errorf("drop_caches value %q is outside 1..4", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRFC5987Encode(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"ubuntu-24.04.iso": "ubuntu-24.04.iso",
|
||||
"a b=c:d@e;f,g'h": "a%20b%3Dc%3Ad%40e%3Bf%2Cg%27h",
|
||||
"!#$&+-.^_`|~": "!#$&+-.^_`|~",
|
||||
"中.txt": "%E4%B8%AD.txt",
|
||||
"100%\"x\\y/z": "100%25%22x%5Cy%2Fz",
|
||||
}
|
||||
for in, want := range tests {
|
||||
if got := rfc5987Encode(in); got != want {
|
||||
t.Errorf("rfc5987Encode(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
header := attachmentHeader(`中 "a".iso`)
|
||||
if !strings.HasPrefix(header, `attachment; filename="_ _a_.iso"; filename*=UTF-8''`) {
|
||||
t.Errorf("attachmentHeader = %q", header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoldsMedia(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite := func(rel string) {
|
||||
full := filepath.Join(root, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
mustWrite("images/win.ISO")
|
||||
mustWrite("share/notes.txt")
|
||||
mustWrite("share/deep/disk.img")
|
||||
mustWrite("docs/readme.txt")
|
||||
|
||||
cases := map[string]bool{
|
||||
"images": true,
|
||||
"images/win.ISO": true,
|
||||
"share": true,
|
||||
"share/notes.txt": false,
|
||||
"docs": false,
|
||||
"missing/new.iso": true,
|
||||
"missing/notes.txt": false,
|
||||
"share/deep/disk.img": true,
|
||||
}
|
||||
for rel, want := range cases {
|
||||
got, err := holdsMedia(filepath.Join(root, rel))
|
||||
if err != nil {
|
||||
t.Fatalf("holdsMedia(%s): %v", rel, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("holdsMedia(%s) = %v, want %v", rel, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,23 @@
|
||||
package storage
|
||||
|
||||
type Service struct{}
|
||||
import "sync"
|
||||
|
||||
// Service backs the virtual media API. The job fields belong to the single
|
||||
// long running media operation (a file being written into a medium); they
|
||||
// follow the download service's shape, so the web UI polls both with the same
|
||||
// vocabulary.
|
||||
type Service struct {
|
||||
jobMutex sync.Mutex
|
||||
jobDone chan struct{}
|
||||
jobWaiters []chan struct{}
|
||||
jobState jobState
|
||||
jobName string
|
||||
jobLoaded int64
|
||||
jobTotal int64
|
||||
jobMessage string
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
startPartitionWatcher()
|
||||
return &Service{jobState: jobStateIdle}
|
||||
}
|
||||
|
||||
@@ -3,23 +3,20 @@ package stream
|
||||
import (
|
||||
"NanoKVM-Server/common"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type H264Frame struct {
|
||||
Data []byte
|
||||
Result int
|
||||
Duration time.Duration
|
||||
Timestamp int64
|
||||
}
|
||||
|
||||
type H264Subscription struct {
|
||||
frames chan H264Frame
|
||||
done chan struct{}
|
||||
closed atomic.Bool
|
||||
once sync.Once
|
||||
waitingForKeyframe bool
|
||||
source *H264Source
|
||||
frames chan H264Frame
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
type H264Source struct {
|
||||
@@ -38,7 +35,8 @@ func SubscribeH264() *H264Subscription {
|
||||
|
||||
func (s *H264Source) subscribe() *H264Subscription {
|
||||
subscription := &H264Subscription{
|
||||
frames: make(chan H264Frame, 4),
|
||||
source: s,
|
||||
frames: make(chan H264Frame),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
@@ -58,6 +56,12 @@ func (s *H264Source) subscribe() *H264Subscription {
|
||||
}
|
||||
|
||||
func (s *H264Subscription) Next() (H264Frame, bool) {
|
||||
select {
|
||||
case <-s.done:
|
||||
return H264Frame{}, false
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case frame := <-s.frames:
|
||||
return frame, true
|
||||
@@ -68,9 +72,8 @@ func (s *H264Subscription) Next() (H264Frame, bool) {
|
||||
|
||||
func (s *H264Subscription) Close() {
|
||||
s.once.Do(func() {
|
||||
s.closed.Store(true)
|
||||
close(s.done)
|
||||
defaultH264Source.remove(s)
|
||||
s.source.remove(s)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -83,18 +86,19 @@ func (s *H264Source) remove(subscription *H264Subscription) {
|
||||
func (s *H264Source) run() {
|
||||
screen := common.GetScreen()
|
||||
common.CheckScreen()
|
||||
fps := screen.FPS
|
||||
ticker := time.NewTicker(time.Second / time.Duration(fps))
|
||||
defer ticker.Stop()
|
||||
|
||||
vision := common.GetKvmVision()
|
||||
startTime := time.Now()
|
||||
s.capture(screen, vision.AcquireH264, vision.StopH264)
|
||||
}
|
||||
|
||||
for range ticker.C {
|
||||
func (s *H264Source) capture(screen *common.Screen, acquire func(uint16, uint16, uint16, uint16) *common.VideoFrame, stop func()) {
|
||||
for {
|
||||
subscribers := s.snapshot()
|
||||
if len(subscribers) == 0 {
|
||||
s.mutex.Lock()
|
||||
if len(s.subscribers) == 0 {
|
||||
if stop != nil {
|
||||
stop()
|
||||
}
|
||||
s.running = false
|
||||
s.mutex.Unlock()
|
||||
return
|
||||
@@ -103,33 +107,21 @@ func (s *H264Source) run() {
|
||||
continue
|
||||
}
|
||||
|
||||
if screen.FPS != fps && screen.FPS != 0 {
|
||||
fps = screen.FPS
|
||||
ticker.Reset(time.Second / time.Duration(fps))
|
||||
}
|
||||
|
||||
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
|
||||
if result < 0 {
|
||||
frame := H264Frame{Result: result}
|
||||
for _, subscription := range subscribers {
|
||||
subscription.send(frame)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
current := acquire(0, 0, screen.BitRate, uint16(screen.FPS))
|
||||
frame := H264Frame{
|
||||
Data: data,
|
||||
Result: result,
|
||||
Duration: time.Second / time.Duration(fps),
|
||||
Timestamp: time.Since(startTime).Microseconds(),
|
||||
Data: current.Data,
|
||||
Result: current.Result,
|
||||
Timestamp: current.Timestamp,
|
||||
}
|
||||
for _, subscription := range subscribers {
|
||||
subscription.send(frame)
|
||||
}
|
||||
GetFrameRateCounter().Update()
|
||||
current.Release()
|
||||
if frame.Result >= 0 && len(frame.Data) > 0 {
|
||||
GetFrameRateCounter().Update()
|
||||
} else {
|
||||
time.Sleep(time.Second / time.Duration(max(screen.FPS, 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,41 +137,15 @@ func (s *H264Source) snapshot() []*H264Subscription {
|
||||
}
|
||||
|
||||
func (s *H264Subscription) send(frame H264Frame) bool {
|
||||
if s.closed.Load() {
|
||||
select {
|
||||
case <-s.done:
|
||||
return false
|
||||
default:
|
||||
}
|
||||
if s.waitingForKeyframe {
|
||||
if frame.Result != 3 {
|
||||
return false
|
||||
}
|
||||
s.waitingForKeyframe = false
|
||||
}
|
||||
|
||||
select {
|
||||
case s.frames <- frame:
|
||||
return true
|
||||
case <-s.done:
|
||||
return false
|
||||
default:
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.frames:
|
||||
case <-s.done:
|
||||
return false
|
||||
default:
|
||||
s.waitingForKeyframe = true
|
||||
if frame.Result != 3 {
|
||||
return false
|
||||
}
|
||||
s.waitingForKeyframe = false
|
||||
select {
|
||||
case s.frames <- frame:
|
||||
return true
|
||||
case <-s.done:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"NanoKVM-Server/common"
|
||||
"NanoKVM-Server/service/stream"
|
||||
"NanoKVM-Server/service/vm"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image/jpeg"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -83,32 +85,69 @@ func (s *Streamer) getClients() []*gin.Context {
|
||||
}
|
||||
|
||||
func (s *Streamer) run() {
|
||||
defer atomic.StoreInt32(&s.running, 0)
|
||||
|
||||
screen := common.GetScreen()
|
||||
common.CheckScreen()
|
||||
fps := screen.FPS
|
||||
|
||||
vision := common.GetKvmVision()
|
||||
s.streamFrames(screen, vision.AcquireMjpeg, vision.StopMjpeg)
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(time.Second / time.Duration(fps))
|
||||
defer ticker.Stop()
|
||||
func (s *Streamer) streamFrames(screen *common.Screen, acquire func(uint16, uint16, uint16, uint16) *common.VideoFrame, stop func()) {
|
||||
frames := make(chan *common.VideoFrame)
|
||||
done := make(chan struct{})
|
||||
captureDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(captureDone)
|
||||
defer stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
frame := acquire(0, 0, screen.Quality, uint16(screen.FPS))
|
||||
select {
|
||||
case frames <- frame:
|
||||
frame.Release()
|
||||
case <-done:
|
||||
frame.Release()
|
||||
return
|
||||
}
|
||||
if frame.Result < 0 {
|
||||
select {
|
||||
case <-time.After(time.Second / time.Duration(max(screen.FPS, 1))):
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for range ticker.C {
|
||||
for {
|
||||
clients := s.getClients()
|
||||
if len(clients) == 0 {
|
||||
log.Debug("mjpeg stream stopped due to no clients")
|
||||
return
|
||||
s.mutex.Lock()
|
||||
if len(s.clients) == 0 {
|
||||
close(done)
|
||||
<-captureDone
|
||||
atomic.StoreInt32(&s.running, 0)
|
||||
s.mutex.Unlock()
|
||||
log.Debug("mjpeg stream stopped due to no clients")
|
||||
return
|
||||
}
|
||||
s.mutex.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
data, result := vision.ReadMjpeg(screen.Width, screen.Height, screen.Quality)
|
||||
frame := <-frames
|
||||
data, result := frame.Data, frame.Result
|
||||
stream.UpdateCaptureStatus(stream.CaptureModeMJPEG, result)
|
||||
if result < 0 || result == 5 || len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if s.frameCacheEnabled() {
|
||||
s.setLatestFrame(data, screen.Width, screen.Height)
|
||||
s.setLatestFrame(data)
|
||||
}
|
||||
|
||||
for _, client := range clients {
|
||||
@@ -118,16 +157,15 @@ func (s *Streamer) run() {
|
||||
}
|
||||
}
|
||||
|
||||
if screen.FPS != fps && screen.FPS != 0 {
|
||||
fps = screen.FPS
|
||||
ticker.Reset(time.Second / time.Duration(fps))
|
||||
}
|
||||
|
||||
stream.GetFrameRateCounter().Update()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Streamer) setLatestFrame(data []byte, width uint16, height uint16) {
|
||||
func (s *Streamer) setLatestFrame(data []byte) {
|
||||
size, err := jpeg.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
frameCopy := append([]byte(nil), data...)
|
||||
|
||||
s.frameMutex.Lock()
|
||||
@@ -135,8 +173,8 @@ func (s *Streamer) setLatestFrame(data []byte, width uint16, height uint16) {
|
||||
|
||||
s.latestFrame = LatestFrame{
|
||||
Data: frameCopy,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Width: uint16(size.Width),
|
||||
Height: uint16(size.Height),
|
||||
CapturedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,24 +5,14 @@ import (
|
||||
"NanoKVM-Server/service/vm"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/rtp/codecs"
|
||||
"github.com/pion/webrtc/v4/pkg/media"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func NewWebRTCManager() *WebRTCManager {
|
||||
m := &WebRTCManager{
|
||||
clients: make(map[*websocket.Conn]*Client),
|
||||
videoPacketizer: rtp.NewPacketizer(
|
||||
1200,
|
||||
100,
|
||||
0x1234ABCD,
|
||||
&codecs.H264Payloader{},
|
||||
rtp.NewRandomSequencer(),
|
||||
90000,
|
||||
),
|
||||
videoSending: false,
|
||||
clients: make(map[*websocket.Conn]*Client),
|
||||
videoPacketizer: newH264Packetizer(),
|
||||
videoSending: false,
|
||||
}
|
||||
m.updateClientSnapshotLocked()
|
||||
|
||||
@@ -85,7 +75,7 @@ func (m *WebRTCManager) StartVideoStream() {
|
||||
m.videoSending = true
|
||||
m.mutex.Unlock()
|
||||
|
||||
go m.sendVideoStream()
|
||||
go m.writeVideoStream()
|
||||
log.Debugf("start sending h264 stream")
|
||||
}
|
||||
|
||||
@@ -101,28 +91,21 @@ func (m *WebRTCManager) stopVideoStreamIfIdle() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *WebRTCManager) sendVideoStream() {
|
||||
func (m *WebRTCManager) writeVideoStream() {
|
||||
subscription := stream.SubscribeH264()
|
||||
defer subscription.Close()
|
||||
samples, writerDone := m.startVideoWriter()
|
||||
|
||||
for {
|
||||
frame, ok := subscription.Next()
|
||||
if !ok {
|
||||
close(samples)
|
||||
<-writerDone
|
||||
return
|
||||
}
|
||||
clients := m.getClients()
|
||||
if len(clients) == 0 {
|
||||
close(samples)
|
||||
<-writerDone
|
||||
if m.stopVideoStreamIfIdle() {
|
||||
log.Debugf("stop sending h264 stream")
|
||||
return
|
||||
}
|
||||
samples, writerDone = m.startVideoWriter()
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -131,33 +114,13 @@ func (m *WebRTCManager) sendVideoStream() {
|
||||
continue
|
||||
}
|
||||
|
||||
sample := media.Sample{
|
||||
Data: frame.Data,
|
||||
Duration: frame.Duration,
|
||||
}
|
||||
|
||||
samples <- sample
|
||||
}
|
||||
}
|
||||
|
||||
func (m *WebRTCManager) startVideoWriter() (chan media.Sample, <-chan struct{}) {
|
||||
samples := make(chan media.Sample, 1)
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
for sample := range samples {
|
||||
packets := m.videoPacketizer.Packetize(sample.Data, uint32(sample.Duration.Seconds()*90000))
|
||||
for _, client := range m.getClients() {
|
||||
err := client.track.writeVideoPackets(packets)
|
||||
if err != nil {
|
||||
log.Errorf("failed to write h264 video to client: %s", err)
|
||||
m.RemoveClient(client.WsConn())
|
||||
client.Close()
|
||||
}
|
||||
packets := m.videoPacketizer.Packetize(frame.Data, frame.Timestamp)
|
||||
for _, client := range clients {
|
||||
if err := client.track.writeVideoPackets(packets); err != nil {
|
||||
log.Errorf("failed to write h264 video to client: %s", err)
|
||||
m.RemoveClient(client.WsConn())
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return samples, done
|
||||
}
|
||||
}
|
||||
|
||||
33
server/service/stream/webrtc/packetizer.go
Normal file
33
server/service/stream/webrtc/packetizer.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/rtp/codecs"
|
||||
)
|
||||
|
||||
type h264Packetizer struct {
|
||||
packetizer rtp.Packetizer
|
||||
lastTicks uint64
|
||||
initialized bool
|
||||
}
|
||||
|
||||
func newH264Packetizer() *h264Packetizer {
|
||||
return &h264Packetizer{
|
||||
packetizer: rtp.NewPacketizer(1200, 100, 0x1234ABCD,
|
||||
&codecs.H264Payloader{}, rtp.NewRandomSequencer(), 90000),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *h264Packetizer) Packetize(data []byte, timestamp int64) []*rtp.Packet {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ticks := uint64(timestamp) * 90 / 1000
|
||||
if p.initialized {
|
||||
p.packetizer.SkipSamples(uint32(ticks - p.lastTicks))
|
||||
}
|
||||
p.lastTicks = ticks
|
||||
p.initialized = true
|
||||
return p.packetizer.Packetize(data, 0)
|
||||
}
|
||||
@@ -5,14 +5,13 @@ import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
type WebRTCManager struct {
|
||||
clients map[*websocket.Conn]*Client
|
||||
clientSnapshot atomic.Pointer[[]*Client]
|
||||
videoPacketizer rtp.Packetizer
|
||||
videoPacketizer *h264Packetizer
|
||||
videoSending bool
|
||||
mutex sync.Mutex
|
||||
viewerVersion uint64
|
||||
|
||||
@@ -13,10 +13,9 @@ import (
|
||||
)
|
||||
|
||||
var screenFileMap = map[string]string{
|
||||
"type": "/kvmapp/kvm/type",
|
||||
"fps": "/kvmapp/kvm/fps",
|
||||
"quality": "/kvmapp/kvm/qlty",
|
||||
"resolution": "/kvmapp/kvm/res",
|
||||
"type": "/kvmapp/kvm/type",
|
||||
"fps": "/kvmapp/kvm/fps",
|
||||
"quality": "/kvmapp/kvm/qlty",
|
||||
}
|
||||
|
||||
func (s *Service) SetScreen(c *gin.Context) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/service/hid"
|
||||
"NanoKVM-Server/service/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -49,11 +50,10 @@ func (s *Service) GetVirtualDevice(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
network, _ := isDeviceExist(virtualNetwork)
|
||||
disk, _ := isDeviceExist(virtualDisk)
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetVirtualDeviceRsp{
|
||||
Network: network,
|
||||
Disk: disk,
|
||||
Disk: diskAttached(),
|
||||
})
|
||||
log.Debugf("get virtual device success")
|
||||
}
|
||||
@@ -69,31 +69,94 @@ func (s *Service) UpdateVirtualDevice(c *gin.Context) {
|
||||
|
||||
var device string
|
||||
var commands []string
|
||||
preparePartition := false
|
||||
|
||||
// A caller that says which state it wants gets that state; a caller that
|
||||
// does not is toggling, the way this endpoint has always worked.
|
||||
switch req.Device {
|
||||
case "network":
|
||||
device = virtualNetwork
|
||||
|
||||
exist, _ := isDeviceExist(device)
|
||||
if !exist {
|
||||
enable := !exist
|
||||
if req.Enable != nil {
|
||||
enable = *req.Enable
|
||||
}
|
||||
if enable && !exist {
|
||||
commands = mountNetworkCommands
|
||||
} else {
|
||||
} else if !enable && exist {
|
||||
commands = unmountNetworkCommands
|
||||
}
|
||||
if len(commands) > 0 {
|
||||
// Restarting the gadget for a network change also makes the boot
|
||||
// script attach whatever /boot/usb.disk0 names. When the virtual
|
||||
// disk is enabled that is the raw data partition by default, so
|
||||
// this transition needs the same protection as a disk mount.
|
||||
diskEnabled, err := isDeviceExist(virtualDisk)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -3, "check virtual disk failed")
|
||||
return
|
||||
}
|
||||
preparePartition = diskEnabled
|
||||
}
|
||||
case "disk":
|
||||
device = virtualDisk
|
||||
|
||||
exist, _ := isDeviceExist(device)
|
||||
if !exist {
|
||||
commands = mountDiskCommands
|
||||
} else {
|
||||
commands = unmountDiskCommands
|
||||
// The settings switch toggles: it reads "on" only while the raw
|
||||
// partition is served, so turning it on while an image is mounted
|
||||
// cancels that mount, which the UI confirms first. An explicit enable
|
||||
// is a request for the virtual disk to exist at all, so it compares
|
||||
// against that instead and leaves a served image alone.
|
||||
enabled, err := isDeviceExist(device)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -3, "check virtual disk failed")
|
||||
return
|
||||
}
|
||||
current := diskAttached()
|
||||
enable := !current
|
||||
if req.Enable != nil {
|
||||
current = enabled
|
||||
enable = *req.Enable
|
||||
}
|
||||
// Asking for the state that already holds changes nothing: a caller
|
||||
// that says "enable" must not switch off a running share.
|
||||
if enable != current {
|
||||
if current {
|
||||
commands = unmountDiskCommands
|
||||
} else {
|
||||
// The restarted gadget attaches the raw partition. The actual
|
||||
// read-only transition happens under the media operation lock
|
||||
// below, before any script can expose the partition.
|
||||
preparePartition = true
|
||||
commands = mountDiskCommands
|
||||
}
|
||||
}
|
||||
default:
|
||||
rsp.ErrRsp(c, -2, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
if len(commands) > 0 && (device == virtualDisk || preparePartition) {
|
||||
unlock, err := storage.LockVirtualDiskTransition()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -3, err.Error())
|
||||
return
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
if preparePartition {
|
||||
// The host writes the partition from now on. A mounted image is
|
||||
// released by the restarted gadget, which attaches the raw
|
||||
// partition again. This must happen before the script can expose
|
||||
// that partition, including when the script is changing networking.
|
||||
if err := storage.SetDataWritable(false); err != nil {
|
||||
log.Errorf("make /data read-only failed: %s", err)
|
||||
rsp.ErrRsp(c, -3, "make /data read-only failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h := hid.GetHid()
|
||||
h.Lock()
|
||||
h.CloseNoLock()
|
||||
@@ -102,14 +165,30 @@ func (s *Service) UpdateVirtualDevice(c *gin.Context) {
|
||||
h.Unlock()
|
||||
}()
|
||||
|
||||
var commandErr error
|
||||
for _, command := range commands {
|
||||
err := exec.Command("sh", "-c", command).Run()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -3, "operation failed")
|
||||
return
|
||||
if commandErr = exec.Command("sh", "-c", command).Run(); commandErr != nil {
|
||||
log.Errorf("run %q failed: %s", command, commandErr)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever the scripts got to, /data belongs to this side again unless
|
||||
// the raw partition really is served to the target machine now. That
|
||||
// covers a failed script, which may have stopped before re-attaching the
|
||||
// partition it was made read-only for, and a network change, after which
|
||||
// the restarted gadget may serve the partition or not.
|
||||
if len(commands) > 0 && (device == virtualDisk || preparePartition) && !diskAttached() {
|
||||
if err := storage.SetDataWritable(true); err != nil {
|
||||
log.Errorf("make /data writable failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if commandErr != nil {
|
||||
rsp.ErrRsp(c, -3, "operation failed")
|
||||
return
|
||||
}
|
||||
|
||||
on, _ := isDeviceExist(device)
|
||||
rsp.OkRspWithData(c, &proto.UpdateVirtualDeviceRsp{
|
||||
On: on,
|
||||
@@ -118,6 +197,14 @@ func (s *Service) UpdateVirtualDevice(c *gin.Context) {
|
||||
log.Debugf("update virtual device %s success", req.Device)
|
||||
}
|
||||
|
||||
// diskAttached reports whether the virtual disk is enabled and the /data
|
||||
// partition is currently shared with the host. A virtual disk that serves an
|
||||
// image, or nothing at all, counts as disabled.
|
||||
func diskAttached() bool {
|
||||
enabled, _ := isDeviceExist(virtualDisk)
|
||||
return enabled && storage.DataDiskAttached()
|
||||
}
|
||||
|
||||
func isDeviceExist(device string) (bool, error) {
|
||||
_, err := os.Stat(device)
|
||||
|
||||
|
||||
@@ -27,9 +27,11 @@ const (
|
||||
clientHeartbeatTimeout = 90 * time.Second
|
||||
)
|
||||
|
||||
func NewClient(ws *websocket.Conn) *Client {
|
||||
func NewClient(ws *websocket.Conn, readOnly bool, username string) *Client {
|
||||
client := &Client{
|
||||
ws: ws,
|
||||
readOnly: readOnly,
|
||||
username: username,
|
||||
hid: hid.GetHid(),
|
||||
manual: inputcontrol.NewManualSession(controlmode.GetManager(), inputcontrol.GetCoordinator()),
|
||||
keyboard: make(chan hid.QueuedReport, 200),
|
||||
@@ -85,6 +87,9 @@ func (c *Client) Read() error {
|
||||
switch data[0] {
|
||||
case Heartbeat:
|
||||
case KeyboardEvent:
|
||||
if c.readOnly {
|
||||
continue
|
||||
}
|
||||
report := data[1:]
|
||||
if len(report) != 8 {
|
||||
log.Debugf("invalid manual keyboard report: %v", report)
|
||||
@@ -92,6 +97,9 @@ func (c *Client) Read() error {
|
||||
}
|
||||
c.queueManualReport(c.keyboard, inputcontrol.ManualKeyboard, report, keyboardReportHeld(report), true)
|
||||
case MouseEvent:
|
||||
if c.readOnly {
|
||||
continue
|
||||
}
|
||||
report := data[1:]
|
||||
if len(report) != 4 && len(report) != 6 {
|
||||
log.Debugf("invalid manual mouse report: %v", report)
|
||||
|
||||
@@ -33,6 +33,16 @@ func (m *Manager) RemoveClient(ws *websocket.Conn) {
|
||||
delete(m.clients, ws)
|
||||
}
|
||||
|
||||
// NotifyUser sends an event to every connection owned by the user, e.g. to
|
||||
// tell the frontend that account permissions changed.
|
||||
func (m *Manager) NotifyUser(username string, event string, data string) {
|
||||
for _, client := range m.GetClients() {
|
||||
if client.username == username {
|
||||
_ = client.Write(event, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) GetClients() []*Client {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -31,7 +32,16 @@ func (s *Service) Connect(c *gin.Context) {
|
||||
|
||||
log.Debug("websocket connected")
|
||||
|
||||
client := NewClient(ws)
|
||||
// View-only sessions keep receiving frames but their keyboard and mouse
|
||||
// reports are dropped.
|
||||
readOnly := true
|
||||
username := ""
|
||||
if principal, ok := middleware.CurrentPrincipal(c); ok {
|
||||
readOnly = !principal.Has(authn.PermConsoleControl)
|
||||
username = principal.Username
|
||||
}
|
||||
|
||||
client := NewClient(ws, readOnly, username)
|
||||
|
||||
manager := GetManager()
|
||||
manager.AddClient(ws, client)
|
||||
|
||||
47
server/service/ws/storage.go
Normal file
47
server/service/ws/storage.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/service/storage"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// StorageEvent tells the web UI that the shared files view was refreshed: the
|
||||
// watcher drops the exFAT metadata caches and reports what the target machine
|
||||
// wrote to the data partition, including the size a transfer has reached. The
|
||||
// listener simply re-reads what it is showing.
|
||||
const StorageEvent = "storage"
|
||||
|
||||
// storageNotifications carries the refresh notifications to their own
|
||||
// goroutine. The watcher refreshes once a second while the target machine owns
|
||||
// the shared files, and a client that stopped reading would otherwise hold it
|
||||
// up for a whole write deadline on every refresh. One slot is enough: a
|
||||
// notification that arrives while one is still going out is not worth a second
|
||||
// write, because the listener reads the current state either way.
|
||||
var storageNotifications = make(chan struct{}, 1)
|
||||
|
||||
func init() {
|
||||
storage.SubscribePartitionChanges(queueStorageChanged)
|
||||
go broadcastStorageChanges()
|
||||
}
|
||||
|
||||
func queueStorageChanged() {
|
||||
select {
|
||||
case storageNotifications <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func broadcastStorageChanges() {
|
||||
for range storageNotifications {
|
||||
broadcastStorageChanged()
|
||||
}
|
||||
}
|
||||
|
||||
func broadcastStorageChanged() {
|
||||
for _, client := range GetManager().GetClients() {
|
||||
if err := client.Write(StorageEvent, "{}"); err != nil {
|
||||
log.Errorf("failed to send storage change: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ type Manager struct {
|
||||
|
||||
type Client struct {
|
||||
ws *websocket.Conn
|
||||
readOnly bool
|
||||
username string
|
||||
hid *hid.Hid
|
||||
manual *inputcontrol.ManualSession
|
||||
keyboard chan hid.QueuedReport
|
||||
|
||||
233
server/utils/mediapath.go
Normal file
233
server/utils/mediapath.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// MediaDirectory is the volume the emulated USB disk exposes: the target
|
||||
// machine sees it as one removable drive, so nothing secret belongs here.
|
||||
//
|
||||
// It is split into two folders so both sides know what they are looking at:
|
||||
// the shared folder is the drop box for exchanging files, the images folder is
|
||||
// this device's own library of disk images.
|
||||
const (
|
||||
MediaDirectory = "/data"
|
||||
ShareDirectory = MediaDirectory + "/share"
|
||||
ImagesDirectory = MediaDirectory + "/images"
|
||||
)
|
||||
|
||||
// maxNameLength is the FAT/exFAT limit in UTF-16 code units; counting runes is
|
||||
// the closest practical approximation on the Go side.
|
||||
const maxNameLength = 255
|
||||
|
||||
// forbiddenNameChars cannot appear in a name on any platform that mounts
|
||||
// FAT/exFAT, so the file manager rejects them before the kernel sees them.
|
||||
const forbiddenNameChars = `<>:"/\|?*`
|
||||
|
||||
// reservedNames are the DOS device names Windows still refuses, with or
|
||||
// without an extension.
|
||||
var reservedNames = map[string]bool{
|
||||
"CON": true, "PRN": true, "AUX": true, "NUL": true,
|
||||
"COM1": true, "COM2": true, "COM3": true, "COM4": true, "COM5": true,
|
||||
"COM6": true, "COM7": true, "COM8": true, "COM9": true,
|
||||
"LPT1": true, "LPT2": true, "LPT3": true, "LPT4": true, "LPT5": true,
|
||||
"LPT6": true, "LPT7": true, "LPT8": true, "LPT9": true,
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidMediaID = errors.New("invalid medium id")
|
||||
ErrInvalidMediaPath = errors.New("invalid media path")
|
||||
)
|
||||
|
||||
// IsMediaFile reports whether name looks like a virtual medium. The emulated
|
||||
// disk only ever exposes disk images, so the file manager refuses to touch
|
||||
// anything else in MediaDirectory.
|
||||
func IsMediaFile(name string) bool {
|
||||
return IsISOFile(name) || strings.HasSuffix(strings.ToLower(filepath.Base(name)), ".img")
|
||||
}
|
||||
|
||||
// IsISOFile reports whether name is an ISO image, which is the one medium
|
||||
// whose contents can be checked before it is mounted.
|
||||
func IsISOFile(name string) bool {
|
||||
return strings.HasSuffix(strings.ToLower(filepath.Base(name)), ".iso")
|
||||
}
|
||||
|
||||
// ResolveMediaID turns a medium id, a path relative to MediaDirectory, into an
|
||||
// absolute path. Every request naming a file on the device goes through here,
|
||||
// so escapes (absolute paths, "..", separators where they do not belong) are
|
||||
// rejected rather than patched up at the call site.
|
||||
func ResolveMediaID(id string) (string, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || strings.ContainsRune(id, 0) {
|
||||
return "", fmt.Errorf("%w: empty", ErrInvalidMediaID)
|
||||
}
|
||||
if strings.HasPrefix(id, "/") || strings.HasPrefix(id, `\`) {
|
||||
return "", fmt.Errorf("%w: must be relative to %s", ErrInvalidMediaID, MediaDirectory)
|
||||
}
|
||||
|
||||
rel := filepath.FromSlash(id)
|
||||
if !filepath.IsLocal(rel) {
|
||||
return "", fmt.Errorf("%w: %s escapes %s", ErrInvalidMediaID, id, MediaDirectory)
|
||||
}
|
||||
|
||||
for _, segment := range strings.Split(filepath.ToSlash(rel), "/") {
|
||||
if err := validateName(segment); err != nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrInvalidMediaID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !IsMediaFile(rel) {
|
||||
return "", fmt.Errorf("%w: %s is not a disk image", ErrInvalidMediaID, id)
|
||||
}
|
||||
|
||||
return canonicalMediaPath(MediaDirectory, rel)
|
||||
}
|
||||
|
||||
// CanonicalMediaPath resolves an absolute medium path to the spelling used by
|
||||
// the directory entries on disk. Like ResolveMediaID, it rejects paths outside
|
||||
// MediaDirectory and names that are not disk images.
|
||||
func CanonicalMediaPath(name string) (string, error) {
|
||||
rel, err := filepath.Rel(MediaDirectory, filepath.Clean(name))
|
||||
if err != nil || !filepath.IsLocal(rel) {
|
||||
return "", fmt.Errorf("%w: %s is outside %s", ErrInvalidMediaID, name, MediaDirectory)
|
||||
}
|
||||
|
||||
return ResolveMediaID(filepath.ToSlash(rel))
|
||||
}
|
||||
|
||||
// CanonicalPathInside resolves rel, a path already checked by
|
||||
// ValidateMediumPath, to the spelling the directory entries under root use.
|
||||
// Unlike CanonicalMediaPath it accepts directories and arbitrary files: a
|
||||
// delete has to compare what it removes against the canonical paths of the
|
||||
// attached and mounted media, and "IMAGES" names the same exFAT directory as
|
||||
// "images".
|
||||
func CanonicalPathInside(root, rel string) (string, error) {
|
||||
if rel == "" || rel == "/" {
|
||||
return root, nil
|
||||
}
|
||||
return canonicalMediaPath(root, filepath.FromSlash(rel))
|
||||
}
|
||||
|
||||
// canonicalMediaPath resolves every existing component to its on-disk name.
|
||||
// exFAT lookups are case-insensitive, while Go string comparisons are not: an
|
||||
// all-lower-case request and an upper-case request can name the same medium.
|
||||
// Prefer an exact match so this also remains deterministic on a case-sensitive
|
||||
// filesystem, and reject ambiguous folds instead of choosing between files.
|
||||
// A missing component is kept as requested so callers can still create a new
|
||||
// image or report the original not-found error.
|
||||
func canonicalMediaPath(root, rel string) (string, error) {
|
||||
segments := strings.Split(filepath.ToSlash(rel), "/")
|
||||
current := root
|
||||
|
||||
for i, segment := range segments {
|
||||
entries, err := os.ReadDir(current)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
parts := append([]string{current}, segments[i:]...)
|
||||
return filepath.Join(parts...), nil
|
||||
}
|
||||
return "", fmt.Errorf("resolve medium path %s: %w", filepath.Join(root, rel), err)
|
||||
}
|
||||
|
||||
matches := make([]string, 0, 1)
|
||||
for _, entry := range entries {
|
||||
if entry.Name() == segment {
|
||||
matches = []string{segment}
|
||||
break
|
||||
}
|
||||
if strings.EqualFold(entry.Name(), segment) {
|
||||
matches = append(matches, entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
if len(matches) > 1 {
|
||||
return "", fmt.Errorf("%w: %s has more than one case-insensitive match", ErrInvalidMediaID, filepath.Join(root, rel))
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
parts := append([]string{current}, segments[i:]...)
|
||||
return filepath.Join(parts...), nil
|
||||
}
|
||||
|
||||
current = filepath.Join(current, matches[0])
|
||||
}
|
||||
|
||||
return current, nil
|
||||
}
|
||||
|
||||
// ValidateMediumPath normalizes a path inside a mounted medium and returns it
|
||||
// slash separated and relative to the medium root ("/" means the root itself).
|
||||
// It takes the same shape the web UI shows, so a leading slash is accepted and
|
||||
// stripped.
|
||||
func ValidateMediumPath(p string) (string, error) {
|
||||
if strings.ContainsRune(p, 0) {
|
||||
return "", fmt.Errorf("%w: contains NUL", ErrInvalidMediaPath)
|
||||
}
|
||||
|
||||
p = strings.TrimPrefix(strings.TrimSpace(p), "/")
|
||||
if p == "" {
|
||||
return "/", nil
|
||||
}
|
||||
|
||||
clean := path.Clean(p)
|
||||
if clean == "." {
|
||||
return "/", nil
|
||||
}
|
||||
if clean == ".." || strings.HasPrefix(clean, "../") {
|
||||
return "", fmt.Errorf("%w: %s escapes the medium", ErrInvalidMediaPath, p)
|
||||
}
|
||||
|
||||
for _, segment := range strings.Split(clean, "/") {
|
||||
if err := validateName(segment); err != nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrInvalidMediaPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
// ValidateFileName checks a single file name for FAT/exFAT compatibility. It
|
||||
// is what an upload's browser-supplied name has to pass before it is written
|
||||
// into a medium.
|
||||
func ValidateFileName(name string) error {
|
||||
return validateName(name)
|
||||
}
|
||||
|
||||
func validateName(name string) error {
|
||||
if name == "" {
|
||||
return errors.New("empty name")
|
||||
}
|
||||
if name == "." || name == ".." {
|
||||
return fmt.Errorf("reserved name %q", name)
|
||||
}
|
||||
if utf8.RuneCountInString(name) > maxNameLength {
|
||||
return fmt.Errorf("name longer than %d characters", maxNameLength)
|
||||
}
|
||||
if strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
|
||||
return fmt.Errorf("name %q ends with a dot or space", name)
|
||||
}
|
||||
|
||||
for _, r := range name {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return fmt.Errorf("name %q contains a control character", name)
|
||||
}
|
||||
if strings.ContainsRune(forbiddenNameChars, r) {
|
||||
return fmt.Errorf("name %q contains %q", name, r)
|
||||
}
|
||||
}
|
||||
|
||||
base := name
|
||||
if i := strings.IndexByte(base, '.'); i >= 0 {
|
||||
base = base[:i]
|
||||
}
|
||||
if reservedNames[strings.ToUpper(base)] {
|
||||
return fmt.Errorf("name %q is reserved on Windows", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
78
server/utils/mediapath_test.go
Normal file
78
server/utils/mediapath_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCanonicalPathInside(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(root, "Images", "Sub"))
|
||||
mustWrite(t, filepath.Join(root, "Images", "Win.iso"))
|
||||
mustWrite(t, filepath.Join(root, "Images", "notes.txt"))
|
||||
|
||||
tests := []struct {
|
||||
rel string
|
||||
want string
|
||||
}{
|
||||
{"/", root},
|
||||
{"images", filepath.Join(root, "Images")},
|
||||
{"IMAGES", filepath.Join(root, "Images")},
|
||||
{"images/sub", filepath.Join(root, "Images", "Sub")},
|
||||
{"images/WIN.ISO", filepath.Join(root, "Images", "Win.iso")},
|
||||
{"IMAGES/NOTES.TXT", filepath.Join(root, "Images", "notes.txt")},
|
||||
// A missing component keeps the requested spelling from there on.
|
||||
{"images/new/File.bin", filepath.Join(root, "Images", "new", "File.bin")},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, err := CanonicalPathInside(root, tt.rel)
|
||||
if err != nil {
|
||||
t.Errorf("CanonicalPathInside(%q) error: %v", tt.rel, err)
|
||||
continue
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("CanonicalPathInside(%q) = %q, want %q", tt.rel, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalPathInsideAmbiguous(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(root, "a"))
|
||||
mustMkdir(t, filepath.Join(root, "A"))
|
||||
if _, err := os.Stat(filepath.Join(root, "a")); err != nil {
|
||||
t.Skip("case-insensitive filesystem")
|
||||
}
|
||||
entries, _ := os.ReadDir(root)
|
||||
if len(entries) != 2 {
|
||||
t.Skip("case-insensitive filesystem")
|
||||
}
|
||||
|
||||
// An exact match wins over a fold.
|
||||
if got, err := CanonicalPathInside(root, "a"); err != nil || got != filepath.Join(root, "a") {
|
||||
t.Errorf("exact match = %q, %v", got, err)
|
||||
}
|
||||
// Two folds and no exact match is refused instead of guessed.
|
||||
mustMkdir(t, filepath.Join(root, "Bb"))
|
||||
mustMkdir(t, filepath.Join(root, "bB"))
|
||||
if _, err := CanonicalPathInside(root, "bb"); !errors.Is(err, ErrInvalidMediaID) {
|
||||
t.Errorf("ambiguous fold error = %v, want ErrInvalidMediaID", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustMkdir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, file string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
91
server/utils/progress.go
Normal file
91
server/utils/progress.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// progressInterval matches the polling cadence of the web UI, so a transfer
|
||||
// never reports a percentage the client has already stopped asking for.
|
||||
const progressInterval = 2500 * time.Millisecond
|
||||
|
||||
// ProgressWriter counts the bytes written through it and reports progress to
|
||||
// onProgress on a fixed ticker. Transfers that take minutes (image downloads
|
||||
// and uploads, media creation) use it to feed the status endpoints the web UI
|
||||
// polls.
|
||||
type ProgressWriter struct {
|
||||
writer io.Writer
|
||||
total atomic.Int64
|
||||
totalSize int64
|
||||
ticker *time.Ticker
|
||||
done chan struct{}
|
||||
stopOnce sync.Once
|
||||
onProgress func(loaded, total int64)
|
||||
}
|
||||
|
||||
// NewProgressWriter wraps writer. totalSize is the expected number of bytes;
|
||||
// when it is unknown (a chunked response, for example) no progress is
|
||||
// reported. onProgress receives the bytes written so far and the total.
|
||||
func NewProgressWriter(writer io.Writer, totalSize int64, onProgress func(loaded, total int64)) *ProgressWriter {
|
||||
pw := &ProgressWriter{
|
||||
writer: writer,
|
||||
totalSize: totalSize,
|
||||
onProgress: onProgress,
|
||||
}
|
||||
pw.startTicker()
|
||||
return pw
|
||||
}
|
||||
|
||||
func (pw *ProgressWriter) startTicker() {
|
||||
pw.ticker = time.NewTicker(progressInterval)
|
||||
pw.done = make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-pw.done:
|
||||
return
|
||||
case <-pw.ticker.C:
|
||||
pw.updateProgress()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop halts the ticker. It is safe to call more than once and on a nil
|
||||
// writer, so callers can defer it unconditionally.
|
||||
func (pw *ProgressWriter) Stop() {
|
||||
if pw == nil {
|
||||
return
|
||||
}
|
||||
pw.stopOnce.Do(func() {
|
||||
pw.ticker.Stop()
|
||||
close(pw.done)
|
||||
})
|
||||
}
|
||||
|
||||
func (pw *ProgressWriter) updateProgress() {
|
||||
if pw.totalSize <= 0 || pw.onProgress == nil {
|
||||
return
|
||||
}
|
||||
|
||||
pw.onProgress(pw.total.Load(), pw.totalSize)
|
||||
}
|
||||
|
||||
// FormatPercentage renders transfer progress the way the status endpoints
|
||||
// report it: values like "42.13%", and an empty string before the first tick
|
||||
// or when the total size is unknown.
|
||||
func FormatPercentage(loaded, total int64) string {
|
||||
if loaded <= 0 || total <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%.2f%%", float64(loaded)/float64(total)*100)
|
||||
}
|
||||
|
||||
func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
||||
n, err := pw.writer.Write(p)
|
||||
pw.total.Add(int64(n))
|
||||
return n, err
|
||||
}
|
||||
25
server/utils/space.go
Normal file
25
server/utils/space.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// Statfs reports the total and available bytes of the filesystem holding path.
|
||||
// It applies no reserve policy on purpose: each caller decides what it has to
|
||||
// keep back, so the numbers stay plain.
|
||||
func Statfs(path string) (total, available uint64, err error) {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs(path, &stat); err != nil {
|
||||
return 0, 0, fmt.Errorf("stat filesystem %s: %w", path, err)
|
||||
}
|
||||
|
||||
blockSize := uint64(stat.Bsize)
|
||||
if blockSize != 0 && (stat.Blocks > math.MaxUint64/blockSize || stat.Bavail > math.MaxUint64/blockSize) {
|
||||
return 0, 0, fmt.Errorf("filesystem size overflow on %s", path)
|
||||
}
|
||||
|
||||
return stat.Blocks * blockSize, stat.Bavail * blockSize, nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package utils
|
||||
|
||||
// TransferSentinelPath coordinates application uploads and virtual-media
|
||||
// transfers. /run is already required by the device init scripts and preserves
|
||||
// TransferSentinelPath coordinates transfers that write new files into the
|
||||
// data partition: application updates and image downloads. Writing into an
|
||||
// image a medium already owns does not take it. /run is already required by the device init scripts and preserves
|
||||
// the previous reboot-clears-stale-state behaviour without using /tmp.
|
||||
const TransferSentinelPath = "/run/nanokvm-transfer-in-progress"
|
||||
|
||||
45
server/webcache/webcache.go
Normal file
45
server/webcache/webcache.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Package webcache labels the static files the server hands out.
|
||||
//
|
||||
// The frontend is one entry document plus content-hashed assets. The document
|
||||
// is what an update replaces, and a browser that keeps it keeps the old app:
|
||||
// the storage window of a release that updates but "looks unchanged" is that
|
||||
// cache. Assets carry a hash in their name, so a name that is already cached
|
||||
// can never change meaning.
|
||||
package webcache
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Headers returns the middleware that sets Cache-Control for the web root.
|
||||
// Paths it does not recognise are left alone.
|
||||
func Headers(root string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Clean first: "/assets/../x" is not an asset, and it must not be
|
||||
// resolved outside root either.
|
||||
requestPath := path.Clean("/" + c.Request.URL.Path)
|
||||
|
||||
switch {
|
||||
case requestPath == "/" || requestPath == "/index.html":
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
case strings.HasPrefix(requestPath, "/assets/") && assetExists(root, requestPath):
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// assetExists reports whether the request names a file the build shipped. A
|
||||
// missing hashed asset is answered with a 404, and marking that immutable would
|
||||
// keep the URL broken in the browser for a year - which is exactly what a
|
||||
// rollback to a build whose assets still exist would run into.
|
||||
func assetExists(root, requestPath string) bool {
|
||||
relative := strings.TrimPrefix(requestPath, "/")
|
||||
info, err := os.Stat(filepath.Join(root, filepath.FromSlash(relative)))
|
||||
return err == nil && info.Mode().IsRegular()
|
||||
}
|
||||
64
server/webcache/webcache_test.go
Normal file
64
server/webcache/webcache_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package webcache
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, content := range map[string]string{
|
||||
"index.html": "x",
|
||||
"assets/index-abc123.js": "x",
|
||||
"assets/style-abc123.css": "x",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(name)), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"/", "no-cache"},
|
||||
{"/index.html", "no-cache"},
|
||||
{"/assets/index-abc123.js", "public, max-age=31536000, immutable"},
|
||||
{"/assets/style-abc123.css", "public, max-age=31536000, immutable"},
|
||||
// A missing asset is answered by a 404, and caching that for a year is
|
||||
// how a rollback to a build whose assets still exist stays broken.
|
||||
{"/assets/missing.js", ""},
|
||||
// Classified after cleaning, so a traversal never reaches the assets
|
||||
// branch or the filesystem.
|
||||
{"/assets/../index.html", "no-cache"},
|
||||
// Everything else keeps the server's default: the icon and the API
|
||||
// must not be pinned to a policy they were never reviewed for.
|
||||
{"/sipeed.ico", ""},
|
||||
{"/mockServiceWorker.js", ""},
|
||||
{"/api/auth/login", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
engine := gin.New()
|
||||
engine.Use(Headers(root))
|
||||
engine.NoRoute(func(c *gin.Context) { c.String(http.StatusNotFound, "") })
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, tt.path, nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
if got := recorder.Header().Get("Cache-Control"); got != tt.want {
|
||||
t.Errorf("%s: Cache-Control = %q, want %q", tt.path, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
# NanoKVM Support Instructions
|
||||
|
||||
## Environment Preparation
|
||||
1. NanoKVM-Lite/Full/PCIe is based on the SG2002 as the main control chip. The projects in the support section are compiled under the [MaixCDK](https://github.com/sipeed/MaixCDK) framework. Before compiling, please ensure that the `MaixCDK` environment is correctly configured. For configuration instructions, click [here](https://github.com/sipeed/MaixCDK/blob/main/docs/doc_zh/README.md).
|
||||
|
||||
NanoKVM-Lite/Full/PCIe uses the SG2002. The system daemon and `libkvm.so` wrapper
|
||||
build with [MaixCDK](https://github.com/sipeed/MaixCDK); `libkvm_mmf.so` builds
|
||||
separately with CMake using the Sophgo sources in that checkout. Configure and
|
||||
activate the [MaixCDK environment](https://github.com/sipeed/MaixCDK/blob/main/docs/doc_zh/README.md)
|
||||
and add the RISC-V musl toolchain to `PATH`. Run the commands below from
|
||||
`support/sg2002/`. Alternatively, `make vision` at the repository root builds
|
||||
the video libraries in Docker. `make app` builds the server using the prebuilt
|
||||
libraries in `server/dl_lib/`.
|
||||
|
||||
## kvm_system Compilation Instructions
|
||||
|
||||
@@ -11,16 +19,17 @@
|
||||
2. Modify the paths of `MAIXCDK_PATH` and `NanoKVM_PATH` in `./build`.
|
||||
3. Execute `./build kvm_system` to compile `kvm_system`.
|
||||
4. Use `scp ./kvm_system/dist/kvm_system_release/kvm_system root@192.168.x.x:/kvmapp/kvm_system` to copy it to NanoKVM for testing.
|
||||
5. Use `./build add_to_kvmapp` to place the executable file into the `/kvmapp` installation package.
|
||||
5. Use `./build kvm_system add_to_kvmapp` to stage the executable in the repository's `kvmapp/` directory.
|
||||
6. Use `./build kvm_system clean` to clean the compilation of `kvm_system`.
|
||||
|
||||
## kvm_vision Compilation Instructions
|
||||
|
||||
> `kvm_vision` refers to the image acquisition and encoding subsystem of NanoKVM, compiled with MaixCDK to produce dynamic libraries for Go calls. Use `kvm_vision_test` to compile and test the dynamic library.
|
||||
> `kvm_vision` builds `libkvm_mmf.so`, then the MaixCDK wrapper `libkvm.so` and the `kvm_vision_test` example.
|
||||
|
||||
1. Before compiling, please ensure that the above-mentioned `MaixCDK` environment is correctly configured.
|
||||
2. Modify the paths of `MAIXCDK_PATH` and `NanoKVM_PATH` in `./build`.
|
||||
3. Execute `./build kvm_vision` to compile `kvm_vision_test`.
|
||||
4. You can test the dynamic libraries in `kvm_vision_test/dist/kvm_vision_test_release/dl_lib/`.
|
||||
5. Use `./build add_to_kvmapp` to place the dynamic libraries into the `/kvmapp` installation package.
|
||||
6. Use `./build kvm_vision clean` to clean the compilation of `kvm_vision`.
|
||||
3. Execute `./build kvm_vision` to build both libraries and the example. The library build and install directories are `kvm_vision_test/build/kvm_mmf-build/` and `kvm_vision_test/build/kvm_mmf-install/`.
|
||||
4. The resulting libraries are in `kvm_vision_test/dist/kvm_vision_test_release/dl_lib/`.
|
||||
5. Run `./build kvm_vision add_to_kvmapp` to stage them in the repository's `kvmapp/server/dl_lib/`.
|
||||
6. After changing the video library interfaces, update both prebuilt files `server/dl_lib/libkvm.so` and `server/dl_lib/libkvm_mmf.so` from the same build. Server compilation uses these files directly; see the [server build instructions](../../server/README.md#compile--deploy).
|
||||
7. Use `./build kvm_vision clean` to remove the build outputs, including both library build and install directories.
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
# NanoKVM support 说明
|
||||
|
||||
## 环境准备
|
||||
1. NanoKVM-Lite/Full/PCIe 以SG2002为主控芯片,support 部分的工程在[MaixCDK](https://github.com/sipeed/MaixCDK) 框架下编译。编译前,请保证`MaixCDK`环境已正确配置,配置教程点击[这里](https://github.com/sipeed/MaixCDK/blob/main/docs/doc_zh/README.md)
|
||||
|
||||
NanoKVM-Lite/Full/PCIe 以 SG2002 为主控芯片。系统程序和 `libkvm.so` 包装层借助
|
||||
[MaixCDK](https://github.com/sipeed/MaixCDK) 编译,`libkvm_mmf.so` 使用该目录中的
|
||||
Sophgo 源码通过 CMake 独立编译。请先配置并激活
|
||||
[MaixCDK 环境](https://github.com/sipeed/MaixCDK/blob/main/docs/doc_zh/README.md),
|
||||
将 RISC-V musl 工具链加入 `PATH`。以下命令在 `support/sg2002/` 下执行。
|
||||
也可以在仓库根目录执行 `make vision`,通过 Docker 构建视频库。
|
||||
`make app` 使用 `server/dl_lib/` 中的预编译库构建服务器。
|
||||
|
||||
## kvm_system 编译说明
|
||||
|
||||
@@ -11,16 +18,17 @@
|
||||
2. 修改 `./build` 中 `MAIXCDK_PATH` 和 `NanoKVM_PATH`的路径
|
||||
3. 执行 `./build kvm_system` 编译 kvm_system
|
||||
4. 使用 `scp ./kvm_system/dist/kvm_system_release/kvm_system root@192.168.x.x:/kvmapp/kvm_system` 拷贝入 NanoKVM 测试
|
||||
5. 使用 `./build add_to_kvmapp` 可以将可执行文件放入 `/kvmapp` 安装包内
|
||||
5. 使用 `./build kvm_system add_to_kvmapp` 将可执行文件放入仓库的 `kvmapp/` 目录
|
||||
6. 使用 `./build kvm_system clean` 可以清除 kvm_system 的编译
|
||||
|
||||
## kvm_vision 编译说明
|
||||
|
||||
> `kvm_vision` 是 NanoKVM 图像获取编码子系统的统称,使用MaixCDK编译出动态库供Go调用,使用 `kvm_vision_test` 编译和测试动态库
|
||||
> `kvm_vision` 先构建 `libkvm_mmf.so`,再构建 MaixCDK 包装层 `libkvm.so` 和 `kvm_vision_test` 示例程序。
|
||||
|
||||
1. 编译前,请保证上述 1.`MaixCDK`环境已正确配置
|
||||
2. 修改 `./build` 中 `MAIXCDK_PATH` 和 `NanoKVM_PATH`的路径
|
||||
3. 执行 `./build kvm_vision` 编译 kvm_vision_test
|
||||
4. 可使用 `kvm_vision_test/dist/kvm_vision_test_release/dl_lib/` 中的动态库测试
|
||||
5. 使用 `./build add_to_kvmapp` 可以将动态库放入 `/kvmapp` 安装包内
|
||||
6. 使用 `./build kvm_vision clean` 可以清除 kvm_system 的编译
|
||||
3. 执行 `./build kvm_vision` 编译两份库和示例程序。库的编译和安装目录分别为 `kvm_vision_test/build/kvm_mmf-build/`、`kvm_vision_test/build/kvm_mmf-install/`。
|
||||
4. 构建后的动态库位于 `kvm_vision_test/dist/kvm_vision_test_release/dl_lib/`。
|
||||
5. 执行 `./build kvm_vision add_to_kvmapp`,将库放入仓库的 `kvmapp/server/dl_lib/`。
|
||||
6. 修改视频库接口后,用同一批构建的两份库更新 `server/dl_lib/libkvm.so` 和 `server/dl_lib/libkvm_mmf.so`。服务器直接使用这些预编译库,编译步骤见[服务器说明](../../server/README_ZH.md#编译部署)。
|
||||
7. 执行 `./build kvm_vision clean` 清理构建产物,包括上述库的编译和安装目录。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
list(APPEND ADD_REQUIREMENTS vision basic peripheral)
|
||||
list(APPEND ADD_REQUIREMENTS kvm_mmf basic peripheral)
|
||||
list(APPEND ADD_INCLUDE "include")
|
||||
list(APPEND ADD_LINK_DEFINITIONS_PRIVATE -Wl,--as-needed)
|
||||
|
||||
|
||||
@@ -1,79 +1,43 @@
|
||||
|
||||
|
||||
#ifndef KVM_VISION_H_
|
||||
#define KVM_VISION_H_
|
||||
|
||||
#include "kvm_mmf.hpp"
|
||||
#include "maix_camera.hpp"
|
||||
#include "maix_basic.hpp"
|
||||
|
||||
#include "maix_i2c.hpp"
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
#include <fcntl.h> /* low-level i/o */
|
||||
#include <unistd.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <pthread.h>
|
||||
#include <time.h>
|
||||
|
||||
// #include "kvm_mmf.hpp"
|
||||
#define IMG_BUFFER_FULL -3
|
||||
#define IMG_VENC_ERROR -2
|
||||
#define IMG_NOT_EXIST -1
|
||||
#define IMG_MJPEG_TYPE 0
|
||||
#define IMG_H264_TYPE_IF 3
|
||||
#define IMG_H264_TYPE_PF 4
|
||||
|
||||
#define IMG_BUFFER_FULL -3
|
||||
#define IMG_VENC_ERROR -2
|
||||
#define IMG_NOT_EXIST -1
|
||||
#define IMG_MJPEG_TYPE 0
|
||||
#define IMG_H264_TYPE_SPS 1
|
||||
#define IMG_H264_TYPE_PPS 2
|
||||
#define IMG_H264_TYPE_IF 3
|
||||
#define IMG_H264_TYPE_PF 4
|
||||
typedef struct {
|
||||
uint8_t *data;
|
||||
uint32_t size;
|
||||
uint64_t timestamp;
|
||||
uint64_t token;
|
||||
uint8_t codec;
|
||||
} kvmv_frame_t;
|
||||
|
||||
#define NORMAL_RES 0
|
||||
#define NEW_RES 1
|
||||
#define UNSUPPORT_RES 2
|
||||
#define UNKNOWN_RES 3
|
||||
#define ERROR_RES 4
|
||||
void kvmv_init(uint8_t debug_info);
|
||||
void kvmv_deinit(void);
|
||||
|
||||
void kvmv_init(uint8_t _debug_info_en = 0);
|
||||
void set_venc_auto_recyc(uint8_t _enable);
|
||||
/**********************************************************************************
|
||||
* @name kvmv_read_img
|
||||
* @author Sipeed BuGu
|
||||
* @date 2024/10/25
|
||||
* @version R1.0
|
||||
* @brief Acquire the encoded image with auto init
|
||||
* @param _width @input: Output image width
|
||||
* @param _height @input: Output image height
|
||||
* @param _type @input: Encode type
|
||||
* @param _qlty @input: MJPEG: (50-100) | H264: (500-10000)
|
||||
* @param _pp_kvm_data @output: Encode data
|
||||
* @param _p_kvmv_data_size @output: Encode data size
|
||||
* @return
|
||||
-7: HDMI INPUT RES ERROR
|
||||
-6: Unsupported resolution, please modify it in the host settings.
|
||||
-5: Retrieving image, please wait
|
||||
-4: Modifying image resolution, please wait
|
||||
-3: img buffer full
|
||||
-2: VENC Errorl
|
||||
-1: No images were acquired
|
||||
0: Acquire MJPEG encoded images
|
||||
1: Acquire H264 encoded images(SPS)[Deprecated]
|
||||
2: Acquire H264 encoded images(PPS)[Deprecated]
|
||||
3: Acquire H264 encoded images(I)
|
||||
4: Acquire H264 encoded images(P)
|
||||
5: IMG not changed
|
||||
**********************************************************************************/
|
||||
int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _qlty, uint8_t** _pp_kvm_data, uint32_t* _p_kvmv_data_size);
|
||||
int free_kvmv_data(uint8_t ** _pp_kvm_data);
|
||||
void free_all_kvmv_data();
|
||||
void set_h264_gop(uint8_t _gop);
|
||||
void set_frame_detact(uint8_t _frame_detact);
|
||||
void kvmv_deinit();
|
||||
uint8_t kvmv_hdmi_control(uint8_t _en);
|
||||
// type: 0 = MJPEG, 1 = H.264. A zero width/height follows the input size.
|
||||
// quality: MJPEG 50..100, H.264 500..10000 Kbps. fps: 1..60.
|
||||
// Returns -7 input error, -6 unsupported size, -5 busy, -4 reconfiguring,
|
||||
// -3 allocation failure, -2 encoder error, -1 no frame, 0 JPEG, 3 IDR,
|
||||
// 4 P-frame, or 5 unchanged JPEG. Release every successful frame once.
|
||||
int kvmv_read_img(uint16_t width, uint16_t height, uint8_t type,
|
||||
uint16_t quality, uint16_t fps, kvmv_frame_t *frame);
|
||||
// Frees the copied bytes and releases the encoder lease. Safe after shutdown.
|
||||
void kvmv_release_frame(kvmv_frame_t *frame);
|
||||
void kvmv_stop_stream(uint8_t type);
|
||||
void set_h264_gop(uint8_t gop);
|
||||
void set_frame_detact(uint8_t interval);
|
||||
uint8_t kvmv_hdmi_control(uint8_t enable);
|
||||
uint8_t kvmv_hdmi_signal_active(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
@@ -1,45 +1,29 @@
|
||||
/**
|
||||
* 待解决的问题:
|
||||
* // 分辨率跟随输出
|
||||
* // 自动切换分辨率
|
||||
* // HDMI分辨率问题
|
||||
* // H264输入空图,VENC容易炸问题
|
||||
* // MJPEG/H264切换时卡退
|
||||
* // 再加一条手动清空全部内存
|
||||
* // deinit时free全部内存
|
||||
* // free 错内存时会炸的问题
|
||||
*/
|
||||
#include "kvm_vision.h"
|
||||
#include "maix_basic.hpp"
|
||||
#include "maix_i2c.hpp"
|
||||
#include "vi_state_shared.hpp"
|
||||
#include "internal/vi_state_writer.hpp"
|
||||
#include "kvm_mmf.hpp"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdarg>
|
||||
#include <mutex>
|
||||
#include <pthread.h>
|
||||
#include <errno.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#define default_venc_chn 1
|
||||
|
||||
#define VENC_MJPEG 0
|
||||
#define VENC_H264 1
|
||||
|
||||
#define KVMV_MAX_TRY_NUM 2
|
||||
#define vi_min_width 32
|
||||
#define vi_min_height 3
|
||||
#define vi_max_width 1920
|
||||
#define vi_max_height 1080
|
||||
|
||||
#define Farame_sample_size 76800 // 320*240
|
||||
|
||||
#define default_vi_width 1920
|
||||
#define default_vi_height 1080
|
||||
#define default_vpss_width 1920
|
||||
#define default_vpss_height 1080
|
||||
#define default_venc_type VENC_MJPEG
|
||||
#define default_mjpeg_qlty 60
|
||||
#define default_h264_qlty 1000
|
||||
#define default_h264_gop 30
|
||||
#define fresh_frame_discard_count 5
|
||||
|
||||
#define kvmv_data_buffer_size 4
|
||||
#define Try_rounds_HDMI_err_res 5
|
||||
|
||||
#define vi_width_path "/kvmapp/kvm/width"
|
||||
@@ -56,7 +40,9 @@
|
||||
#define LT6911_READ 0xFF
|
||||
#define LT6911_WRITE 0x00
|
||||
|
||||
pthread_mutex_t vi_mutex;
|
||||
std::mutex capture_mutex[2];
|
||||
pthread_t detection_thread, watchdog_thread;
|
||||
bool detection_started = false, watchdog_started = false;
|
||||
pthread_mutex_t hdmi_signal_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static char NanoKVM_edit[] = {
|
||||
@@ -75,56 +61,30 @@ using namespace maix::sys;
|
||||
using namespace maix::peripheral;
|
||||
i2c::I2C LT6911_i2c(4, i2c::Mode::MASTER);
|
||||
|
||||
typedef struct {
|
||||
uint16_t vi_width = default_vi_width;
|
||||
uint16_t vi_height = default_vi_height;
|
||||
uint16_t vpss_width = default_vpss_width;
|
||||
uint16_t vpss_height = default_vpss_height;
|
||||
uint8_t venc_type;
|
||||
uint16_t qlty;
|
||||
uint8_t stream_stop;
|
||||
uint8_t frame_detact = 0;
|
||||
uint8_t display;
|
||||
uint8_t reinit_flag = 1;
|
||||
uint8_t reopen_cam_flag = 0;
|
||||
uint8_t hdmi_cable_state = 0;
|
||||
uint8_t try_exit_thread = 0;
|
||||
uint8_t thread_is_running = 0;
|
||||
uint8_t Auto_res = 0;
|
||||
enum ResolutionState { NORMAL_RES, NEW_RES, UNSUPPORT_RES, UNKNOWN_RES, ERROR_RES };
|
||||
|
||||
struct {
|
||||
std::atomic<uint16_t> vi_width{default_vi_width};
|
||||
std::atomic<uint16_t> vi_height{default_vi_height};
|
||||
uint8_t frame_detact = 0;
|
||||
std::atomic<bool> reopen_cam_flag{false};
|
||||
std::atomic<bool> try_exit_thread{false};
|
||||
uint8_t hdmi_version = 0;
|
||||
uint8_t hw_version = 0;
|
||||
uint8_t hdmi_stop_flag = 0;
|
||||
uint8_t hdmi_reading_flag = 0;
|
||||
std::atomic<bool> hdmi_stop_flag{false};
|
||||
std::atomic<bool> hdmi_reading_flag{false};
|
||||
uint8_t hdmi_mode = 0;
|
||||
uint8_t hdmi_res_type = 0;
|
||||
uint8_t hdmi_res_err = 0;
|
||||
std::atomic<uint8_t> hdmi_res_type{NORMAL_RES};
|
||||
std::atomic<uint8_t> hdmi_res_err{NORMAL_RES};
|
||||
uint8_t hdmi_try_rounds = 0;
|
||||
uint8_t vi_detect_state = 0;
|
||||
uint8_t venc_auto_recyc = 0;
|
||||
uint8_t fresh_frame_count = 0;
|
||||
} kvmv_cfg_t;
|
||||
std::atomic<uint8_t> vi_detect_state{0};
|
||||
} kvmv_cfg;
|
||||
|
||||
typedef struct {
|
||||
uint8_t* p_img_data = NULL;
|
||||
uint8_t img_data_type = 0;
|
||||
uint32_t img_data_size = 0;
|
||||
} kvmv_data_t;
|
||||
|
||||
typedef struct {
|
||||
uint8_t mmf_venc_chn;
|
||||
uint8_t enc_h264_running;
|
||||
uint8_t enc_h264_init;
|
||||
mmf_venc_cfg_t kvm_venc_cfg;
|
||||
} kvm_venc_t;
|
||||
|
||||
camera::Camera *cam = new camera::Camera(default_vpss_width, default_vpss_height, image::FMT_YVU420SP);
|
||||
// camera::Camera *cam = new camera::Camera(320, 240, image::FMT_RGB888);
|
||||
|
||||
kvmv_cfg_t kvmv_cfg;
|
||||
|
||||
kvmv_data_t kvmv_data_buffer[kvmv_data_buffer_size];
|
||||
|
||||
uint8_t kvmv_data_buffer_index = 0;
|
||||
struct {
|
||||
uint64_t hash = 0;
|
||||
uint8_t count = 0;
|
||||
bool unchanged = false;
|
||||
} jpeg_detection;
|
||||
|
||||
uint8_t debug_en = 0;
|
||||
uint8_t last_vi_state_code = 0;
|
||||
@@ -200,16 +160,13 @@ static void set_hdmi_capture_enabled(uint8_t enabled)
|
||||
pthread_mutex_unlock(&hdmi_signal_mutex);
|
||||
}
|
||||
|
||||
static void set_hdmi_detection_state(uint8_t active)
|
||||
{
|
||||
__atomic_store_n(&kvmv_cfg.hdmi_cable_state, active != 0 ? 1 : 0, __ATOMIC_RELEASE);
|
||||
set_hdmi_signal_state(active);
|
||||
}
|
||||
|
||||
void debug(const char *format, ...)
|
||||
{
|
||||
if(debug_en){
|
||||
printf(format);
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
vprintf(format, args);
|
||||
va_end(args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,17 +176,10 @@ uint8_t refresh_vi_state()
|
||||
// that the publication cadence measures wall-clock time, not read time plus it.
|
||||
last_vi_state_refresh_ms = vi_state_shared::monotonic_ms();
|
||||
last_vi_state_code = vi_state_shared::refresh();
|
||||
set_hdmi_detection_state(last_vi_state_code == 1 || last_vi_state_code >= 3);
|
||||
set_hdmi_signal_state(last_vi_state_code == 1 || last_vi_state_code >= 3);
|
||||
return last_vi_state_code;
|
||||
}
|
||||
|
||||
uint8_t to_roll(int8_t _input)
|
||||
{
|
||||
if(_input < 0) return _input + kvmv_data_buffer_size;
|
||||
if(_input >= kvmv_data_buffer_size) return _input - kvmv_data_buffer_size;
|
||||
return _input;
|
||||
}
|
||||
|
||||
int maxmin_data(int _max, int _min, int _data)
|
||||
{
|
||||
if(_data > _max) return _max;
|
||||
@@ -237,17 +187,6 @@ int maxmin_data(int _max, int _min, int _data)
|
||||
return _data;
|
||||
}
|
||||
|
||||
kvmv_data_t* get_save_buffer()
|
||||
{
|
||||
kvmv_data_buffer_index = to_roll(kvmv_data_buffer_index + 1);
|
||||
// debug("[kvmv]kvmv_data_buffer_index = %d\n", kvmv_data_buffer_index);
|
||||
// debug("[kvmv]kvmv_data_buffer.p_img_data = %d\n", kvmv_data_buffer[kvmv_data_buffer_index].p_img_data);
|
||||
if(kvmv_data_buffer[kvmv_data_buffer_index].p_img_data == NULL){
|
||||
return &kvmv_data_buffer[kvmv_data_buffer_index];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// ====HDMI RES==================================================
|
||||
|
||||
uint16_t hdmi_res_list[][2] = {
|
||||
@@ -435,12 +374,12 @@ int get_manual_resolution(void)
|
||||
// res change ?
|
||||
if(kvmv_cfg.vi_width != tmp_width){
|
||||
kvmv_cfg.vi_width = tmp_width;
|
||||
printf("[kvmk] get new width = %d\n", kvmv_cfg.vi_width);
|
||||
printf("[kvmk] get new width = %d\n", kvmv_cfg.vi_width.load());
|
||||
res = 1;
|
||||
}
|
||||
if(kvmv_cfg.vi_height != tmp_height){
|
||||
kvmv_cfg.vi_height = tmp_height;
|
||||
printf("[kvmk] get new height = %d\n", kvmv_cfg.vi_height);
|
||||
printf("[kvmk] get new height = %d\n", kvmv_cfg.vi_height.load());
|
||||
res = 1;
|
||||
}
|
||||
return res;
|
||||
@@ -452,12 +391,12 @@ uint8_t auto_try_res()
|
||||
uint8_t err_code;
|
||||
uint8_t auto_trying_times = 0;
|
||||
|
||||
for (auto_trying_times = 0; auto_trying_times < sizeof(hdmi_res_list)/4; auto_trying_times++){
|
||||
for (auto_trying_times = 0; auto_trying_times < sizeof(hdmi_res_list)/4 && !kvmv_cfg.try_exit_thread.load(); auto_trying_times++){
|
||||
err_code = refresh_vi_state();
|
||||
switch(err_code){
|
||||
case 0:
|
||||
// shouldn't be possible to run here
|
||||
cam->restart(default_vpss_width, default_vpss_height, image::FMT_YVU420SP);
|
||||
kvm_mmf::restart();
|
||||
printf("[kvmv] VI not init\n");
|
||||
break;
|
||||
case 1:
|
||||
@@ -484,8 +423,8 @@ uint8_t auto_try_res()
|
||||
|
||||
kvmv_cfg.vi_width = hdmi_res_list[auto_trying_times][0];
|
||||
kvmv_cfg.vi_height = hdmi_res_list[auto_trying_times][1];
|
||||
printf("[kvmv] restart cam...\n");
|
||||
cam->restart(default_vpss_width, default_vpss_height, image::FMT_YVU420SP);
|
||||
printf("[kvmv] restart video...\n");
|
||||
kvm_mmf::restart();
|
||||
time::sleep_ms(50);
|
||||
break;
|
||||
case 7: // Unknown reason
|
||||
@@ -1121,7 +1060,7 @@ void* watchdog_sf_feed(void * arg)
|
||||
{
|
||||
while(true)
|
||||
{
|
||||
if(kvmv_cfg.try_exit_thread == 1)
|
||||
if(kvmv_cfg.try_exit_thread.load() == 1)
|
||||
break;
|
||||
time::sleep_ms(500);
|
||||
if (watchdog_sf_is_open()){
|
||||
@@ -1133,6 +1072,7 @@ void* watchdog_sf_feed(void * arg)
|
||||
vision_update_watchdog();
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void get_hdmi_version()
|
||||
@@ -1172,6 +1112,16 @@ void get_hdmi_version()
|
||||
}
|
||||
}
|
||||
|
||||
static uint8_t update_hdmi_resolution()
|
||||
{
|
||||
uint16_t width = kvmv_cfg.vi_width;
|
||||
uint16_t height = kvmv_cfg.vi_height;
|
||||
uint8_t result = lt6911_get_csi_res(&width, &height);
|
||||
kvmv_cfg.vi_width = width;
|
||||
kvmv_cfg.vi_height = height;
|
||||
return result;
|
||||
}
|
||||
|
||||
void* vi_subsystem_detection(void * arg)
|
||||
{
|
||||
uint64_t __attribute__((unused)) int_time;
|
||||
@@ -1184,7 +1134,6 @@ void* vi_subsystem_detection(void * arg)
|
||||
uint8_t falling_times = 0;
|
||||
uint8_t cam_need_restart = 0;
|
||||
uint8_t auto_change_mode = 0;
|
||||
kvmv_cfg.thread_is_running = 1;
|
||||
if(access("/proc/lt_int", F_OK) != 0){
|
||||
time::sleep_ms(10);
|
||||
debug("[hdmi]/proc/lt_int not ok\n");
|
||||
@@ -1196,7 +1145,7 @@ void* vi_subsystem_detection(void * arg)
|
||||
last_vi_state_refresh_ms = vi_state_shared::monotonic_ms() - vi_state_publish_interval_ms;
|
||||
while(true)
|
||||
{
|
||||
if(kvmv_cfg.try_exit_thread == 1)
|
||||
if(kvmv_cfg.try_exit_thread.load() == 1)
|
||||
break;
|
||||
|
||||
uint8_t get_new_hdmi_mode = get_hdmi_mode();
|
||||
@@ -1258,8 +1207,8 @@ void* vi_subsystem_detection(void * arg)
|
||||
if(lt6911_get_hdmi_res()){
|
||||
// hdmi get res
|
||||
debug("[hdmi] C HDMI cable insertion!\n");
|
||||
set_hdmi_detection_state(1);
|
||||
kvmv_cfg.hdmi_res_type = lt6911_get_csi_res(&kvmv_cfg.vi_width, &kvmv_cfg.vi_height);
|
||||
set_hdmi_signal_state(1);
|
||||
kvmv_cfg.hdmi_res_type = update_hdmi_resolution();
|
||||
if (kvmv_cfg.hdmi_res_type == NEW_RES) kvmv_cfg.reopen_cam_flag = 1;
|
||||
else if (kvmv_cfg.hdmi_res_type == UNKNOWN_RES){
|
||||
/* Move HDMI resolution modification directly
|
||||
@@ -1270,7 +1219,7 @@ void* vi_subsystem_detection(void * arg)
|
||||
} else {
|
||||
// HDMI res = 0*0/x*0
|
||||
debug("[hdmi] C HDMI cable unplugged!\n");
|
||||
set_hdmi_detection_state(0);
|
||||
set_hdmi_signal_state(0);
|
||||
}
|
||||
lt6911_disable();
|
||||
}
|
||||
@@ -1283,8 +1232,8 @@ void* vi_subsystem_detection(void * arg)
|
||||
if(lt6911_get_hdmi_res()){
|
||||
// hdmi get res
|
||||
debug("[hdmi] UXC HDMI cable insertion!\n");
|
||||
set_hdmi_detection_state(1);
|
||||
kvmv_cfg.hdmi_res_type = lt6911_get_csi_res(&kvmv_cfg.vi_width, &kvmv_cfg.vi_height);
|
||||
set_hdmi_signal_state(1);
|
||||
kvmv_cfg.hdmi_res_type = update_hdmi_resolution();
|
||||
if (kvmv_cfg.hdmi_res_type == NEW_RES) kvmv_cfg.reopen_cam_flag = 1;
|
||||
else if (kvmv_cfg.hdmi_res_type == UNKNOWN_RES){
|
||||
/* Move HDMI resolution modification directly
|
||||
@@ -1295,7 +1244,7 @@ void* vi_subsystem_detection(void * arg)
|
||||
} else {
|
||||
// HDMI res = 0*0/x*0
|
||||
debug("[hdmi] UXC HDMI cable unplugged!\n");
|
||||
set_hdmi_detection_state(0);
|
||||
set_hdmi_signal_state(0);
|
||||
}
|
||||
lt6911_disable();
|
||||
}
|
||||
@@ -1308,8 +1257,8 @@ void* vi_subsystem_detection(void * arg)
|
||||
if(lt6911_get_hdmi_res()){
|
||||
// hdmi get res
|
||||
debug("[hdmi] D HDMI cable insertion!\n");
|
||||
set_hdmi_detection_state(1);
|
||||
kvmv_cfg.hdmi_res_type = lt6911_get_csi_res(&kvmv_cfg.vi_width, &kvmv_cfg.vi_height);
|
||||
set_hdmi_signal_state(1);
|
||||
kvmv_cfg.hdmi_res_type = update_hdmi_resolution();
|
||||
if (kvmv_cfg.hdmi_res_type == NEW_RES) kvmv_cfg.reopen_cam_flag = 1;
|
||||
else if (kvmv_cfg.hdmi_res_type == UNKNOWN_RES){
|
||||
/* Move HDMI resolution modification directly
|
||||
@@ -1320,7 +1269,7 @@ void* vi_subsystem_detection(void * arg)
|
||||
} else {
|
||||
// HDMI res = 0*0/x*0
|
||||
debug("[hdmi] D HDMI cable unplugged!\n");
|
||||
set_hdmi_detection_state(0);
|
||||
set_hdmi_signal_state(0);
|
||||
}
|
||||
lt6911_disable();
|
||||
}
|
||||
@@ -1392,8 +1341,8 @@ void* vi_subsystem_detection(void * arg)
|
||||
if (kvmv_cfg.vi_detect_state == 1){
|
||||
// detect_res
|
||||
if (get_manual_resolution()) {
|
||||
debug("[kvmv] restart cam...\n");
|
||||
cam->restart(default_vpss_width, default_vpss_height, image::FMT_YVU420SP);
|
||||
debug("[kvmv] restart video...\n");
|
||||
kvm_mmf::restart();
|
||||
}
|
||||
|
||||
// Sample after a manual resolution change.
|
||||
@@ -1442,554 +1391,121 @@ void* vi_subsystem_detection(void * arg)
|
||||
|
||||
time::sleep_ms(10);
|
||||
}
|
||||
kvmv_cfg.thread_is_running = 0;
|
||||
}
|
||||
|
||||
int sync_vi_res()
|
||||
{
|
||||
int res = 0;
|
||||
uint8_t RW_Data[35];
|
||||
FILE *fp;
|
||||
int file_size;
|
||||
uint16_t tmp16;
|
||||
|
||||
// vi_width:
|
||||
if (access(vi_width_path, F_OK) != 0){
|
||||
kvmv_cfg.vi_width = default_vi_width;
|
||||
kvmv_cfg.vi_height = default_vi_height;
|
||||
res = -1;
|
||||
return res;
|
||||
} else {
|
||||
fp = fopen(vi_width_path, "r");
|
||||
fseek(fp, 0, SEEK_END);
|
||||
file_size = ftell(fp);
|
||||
fseek(fp, 0, SEEK_SET);
|
||||
fread(RW_Data, sizeof(char), file_size, fp);
|
||||
fclose(fp);
|
||||
RW_Data[file_size] = 0;
|
||||
tmp16 = atoi((char*)RW_Data);
|
||||
if(tmp16 != kvmv_cfg.vi_width){
|
||||
kvmv_cfg.vi_width = tmp16;
|
||||
debug("[hdmi] Get new HDMI width = %d\r\n", kvmv_cfg.vi_width);
|
||||
res = 1;
|
||||
}
|
||||
}
|
||||
// vi_height:
|
||||
if (access(vi_height_path, F_OK) != 0){
|
||||
kvmv_cfg.vi_height = default_vi_height;
|
||||
res = -1;
|
||||
return res;
|
||||
} else {
|
||||
fp = fopen(vi_height_path, "r");
|
||||
fseek(fp, 0, SEEK_END);
|
||||
file_size = ftell(fp);
|
||||
fseek(fp, 0, SEEK_SET);
|
||||
fread(RW_Data, sizeof(char), file_size, fp);
|
||||
fclose(fp);
|
||||
RW_Data[file_size] = 0;
|
||||
tmp16 = atoi((char*)RW_Data);
|
||||
if(tmp16 != kvmv_cfg.vi_height){
|
||||
kvmv_cfg.vi_height = tmp16;
|
||||
debug("[hdmi] Get new HDMI height = %d\r\n", kvmv_cfg.vi_height);
|
||||
res = 1;
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
uint8_t frame_changed(image::Image *raw)
|
||||
{
|
||||
static int raw_size = 0;
|
||||
static uint8_t Farame_sample[Farame_sample_size] = {0};
|
||||
uint8_t ret = 0;
|
||||
uint8_t sample_byte;
|
||||
if(raw->data_size() != raw_size){
|
||||
raw_size = raw->data_size();
|
||||
ret = 1;
|
||||
}
|
||||
int Detection_Pixel_Interval = raw_size/(Farame_sample_size*1.5);
|
||||
for(int i = 0; i < Farame_sample_size; i ++){
|
||||
// printf("[kvmv] i = %d\n", i);
|
||||
if(i >= raw_size){
|
||||
ret = 0;
|
||||
}
|
||||
sample_byte = *(uint8_t*)(raw->data()+(i*Detection_Pixel_Interval));
|
||||
if(sample_byte != Farame_sample[i]){
|
||||
Farame_sample[i] = sample_byte;
|
||||
ret = 1;
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
bool jpg_dump(kvmv_data_t* dump_to, image::Image *raw)
|
||||
{
|
||||
if(dump_to == NULL || raw == NULL || raw->data() == NULL || raw->data_size() == 0){
|
||||
return false;
|
||||
}
|
||||
dump_to->p_img_data = (uint8_t *)malloc(raw->data_size());
|
||||
if(dump_to->p_img_data == NULL){
|
||||
dump_to->img_data_size = 0;
|
||||
dump_to->img_data_type = 0;
|
||||
return false;
|
||||
}
|
||||
dump_to->img_data_size = raw->data_size();
|
||||
dump_to->img_data_type = VENC_MJPEG;
|
||||
memcpy(dump_to->p_img_data, (uint8_t *)raw->data(), raw->data_size());
|
||||
return true;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
uint8_t kvmvenc_gop = default_h264_gop;
|
||||
kvm_venc_t kvm_venc;
|
||||
mmf_venc_cfg_t cfg;
|
||||
void init_venc_h264(uint16_t _width, uint16_t _height, uint16_t _qlty)
|
||||
|
||||
void set_h264_gop(uint8_t gop)
|
||||
{
|
||||
cfg.type = 2; //1, h265, 2, h264
|
||||
cfg.w = _width;
|
||||
cfg.h = _height;
|
||||
cfg.fmt = mmf_invert_format_to_mmf(image::Format::FMT_YVU420SP);
|
||||
cfg.jpg_quality = 0; // unused
|
||||
cfg.gop = kvmvenc_gop;
|
||||
cfg.intput_fps = 60;
|
||||
cfg.output_fps = 60;
|
||||
cfg.bitrate = _qlty; // 码率
|
||||
|
||||
kvm_venc.mmf_venc_chn = default_venc_chn;
|
||||
kvm_venc.enc_h264_init = 0;
|
||||
kvm_venc.kvm_venc_cfg = cfg;
|
||||
|
||||
// if(mmf_vdec_is_used(kvm_venc.mmf_venc_chn)){
|
||||
mmf_del_venc_channel(kvm_venc.mmf_venc_chn);
|
||||
// }
|
||||
if (0 != mmf_add_venc_channel(kvm_venc.mmf_venc_chn, &kvm_venc.kvm_venc_cfg)) {
|
||||
err::check_raise(err::ERR_RUNTIME, "mmf venc init failed!");
|
||||
}
|
||||
kvm_venc.enc_h264_init = 1;
|
||||
|
||||
// init_kvm_h264_stream(&kvm_h264_stream, mmf_stream_buf);
|
||||
// init_h264_stream_struct(&kvm_h264_stream);
|
||||
__atomic_store_n(&kvmvenc_gop, (uint8_t)maxmin_data(100, 1, gop), __ATOMIC_RELEASE);
|
||||
}
|
||||
|
||||
int h264_stream_dump(kvmv_data_t* dump_to, mmf_stream_t* dump_from)
|
||||
void set_frame_detact(uint8_t interval)
|
||||
{
|
||||
static int8_t I_Frame_index = -1;
|
||||
// debug("[kvmv]dump_from->count = %d\n", dump_from->count);
|
||||
if (dump_from->count == 3) {
|
||||
std::lock_guard<std::mutex> lock(capture_mutex[VENC_MJPEG]);
|
||||
kvmv_cfg.frame_detact = maxmin_data(100, 0, interval);
|
||||
jpeg_detection = {};
|
||||
}
|
||||
|
||||
dump_to->p_img_data = (uint8_t *)malloc(dump_from->data_size[0]+dump_from->data_size[1]+dump_from->data_size[2]);
|
||||
dump_to->img_data_size = dump_from->data_size[0]+dump_from->data_size[1]+dump_from->data_size[2];
|
||||
dump_to->img_data_type = IMG_H264_TYPE_IF;
|
||||
memcpy(dump_to->p_img_data, dump_from->data[0], dump_from->data_size[0]);
|
||||
memcpy(dump_to->p_img_data+dump_from->data_size[0], dump_from->data[1], dump_from->data_size[1]);
|
||||
memcpy(dump_to->p_img_data+dump_from->data_size[0]+dump_from->data_size[1], dump_from->data[2], dump_from->data_size[2]);
|
||||
|
||||
debug("[kvmv]SPS size = %d\n", dump_from->data_size[0]);
|
||||
debug("[kvmv]PPS size = %d\n", dump_from->data_size[1]);
|
||||
debug("[kvmv]I-Frame size = %d\n", dump_from->data_size[2]);
|
||||
|
||||
return IMG_H264_TYPE_IF;
|
||||
|
||||
} else if (dump_from->count == 1) {
|
||||
// debug("[kvmv]dump P-Frame\r\n");
|
||||
I_Frame_index = -1;
|
||||
dump_to->p_img_data = (uint8_t *)malloc(dump_from->data_size[0]);
|
||||
dump_to->img_data_size = dump_from->data_size[0];
|
||||
dump_to->img_data_type = IMG_H264_TYPE_PF;
|
||||
memcpy(dump_to->p_img_data, dump_from->data[0], dump_from->data_size[0]);
|
||||
return IMG_H264_TYPE_PF;
|
||||
} else {
|
||||
debug("[kvmv]venc error!\r\n");
|
||||
return IMG_VENC_ERROR;
|
||||
void kvmv_init(uint8_t debug_info)
|
||||
{
|
||||
debug_en = debug_info != 0;
|
||||
kvmv_cfg.try_exit_thread = false;
|
||||
if (!kvm_mmf::initialize()) {
|
||||
fprintf(stderr, "[kvmv] video initialization failed\n");
|
||||
}
|
||||
if (!detection_started) {
|
||||
detection_started = pthread_create(&detection_thread, nullptr, vi_subsystem_detection, nullptr) == 0;
|
||||
}
|
||||
if (!watchdog_started) {
|
||||
watchdog_started = pthread_create(&watchdog_thread, nullptr, watchdog_sf_feed, nullptr) == 0;
|
||||
}
|
||||
}
|
||||
|
||||
void set_h264_gop(uint8_t _gop)
|
||||
int kvmv_read_img(uint16_t width, uint16_t height, uint8_t type,
|
||||
uint16_t quality, uint16_t fps, kvmv_frame_t *out)
|
||||
{
|
||||
kvm_venc.enc_h264_init = 0; // call
|
||||
kvmvenc_gop = maxmin_data(100, 1, (int)_gop);
|
||||
debug("[kvmv] set_h264_gop = %d\n", kvmvenc_gop);
|
||||
}
|
||||
|
||||
void set_frame_detact(uint8_t _frame_detact)
|
||||
{
|
||||
uint8_t frame_detact = maxmin_data(100, 0, (int)_frame_detact);
|
||||
kvmv_cfg.frame_detact = frame_detact;
|
||||
debug("[kvmv] set_frame_detact = %d\n", kvmv_cfg.frame_detact);
|
||||
kvmv_cfg.stream_stop = 0;
|
||||
}
|
||||
|
||||
int8_t raw_to_h264(image::Image *raw, kvmv_data_t* ret_stream, uint16_t _qlty)
|
||||
{
|
||||
uint64_t __attribute__((unused)) start_time;
|
||||
uint64_t __attribute__((unused)) frame_time;
|
||||
int8_t ret = 0;
|
||||
static uint8_t P_Frame_Count = 0;
|
||||
// start_time = time::time_ms();
|
||||
// log::info("getimg: %d \r\n", (int)(time::time_ms()));
|
||||
mmf_stream_t _stream = {0};
|
||||
if(kvm_venc.enc_h264_init != 1 || raw->width() != kvm_venc.kvm_venc_cfg.w || raw->height() != kvm_venc.kvm_venc_cfg.h || _qlty != kvm_venc.kvm_venc_cfg.bitrate){
|
||||
debug("[kvmv]init_venc_h264 enc_h264_init = %d; raw->width() = %d | %d raw->height() = %d | %d \n",
|
||||
kvm_venc.enc_h264_init,
|
||||
raw->width(), kvm_venc.kvm_venc_cfg.w,
|
||||
raw->height(), kvm_venc.kvm_venc_cfg.h);
|
||||
|
||||
init_venc_h264(raw->width(), raw->height(), _qlty);
|
||||
debug("[kvmv]init_venc_h264 finish enc_h264_init = %d; raw->width() = %d | %d raw->height() = %d | %d \n",
|
||||
kvm_venc.enc_h264_init,
|
||||
raw->width(), kvm_venc.kvm_venc_cfg.w,
|
||||
raw->height(), kvm_venc.kvm_venc_cfg.h);
|
||||
// if(kvm_venc.enc_h264_init == 1){
|
||||
// init_venc_h264(raw->width(), raw->height(), _qlty);
|
||||
// } else {
|
||||
// init_venc_h264(default_vpss_width, default_vpss_height, default_h264_qlty);
|
||||
// }
|
||||
if (!out) return IMG_VENC_ERROR;
|
||||
*out = {};
|
||||
if (type > VENC_H264) return IMG_VENC_ERROR;
|
||||
std::lock_guard<std::mutex> lock(capture_mutex[type]);
|
||||
if (kvmv_cfg.try_exit_thread || kvmv_cfg.hdmi_stop_flag) return IMG_NOT_EXIST;
|
||||
if (kvmv_cfg.hdmi_res_err == ERROR_RES) return -7;
|
||||
if (kvmv_cfg.hdmi_res_type == UNSUPPORT_RES) return -6;
|
||||
if (kvmv_cfg.vi_detect_state == 1) return -4;
|
||||
if (kvmv_cfg.reopen_cam_flag.exchange(false) && !kvm_mmf::restart()) return IMG_VENC_ERROR;
|
||||
if (width == 0 || height == 0) {
|
||||
width = kvmv_cfg.vi_width;
|
||||
height = kvmv_cfg.vi_height;
|
||||
}
|
||||
// log::info("init(): %d \r\n", (int)(time::time_ms() - start_time));
|
||||
if (mmf_venc_push(kvm_venc.mmf_venc_chn, (uint8_t *)raw->data(), raw->width(), raw->height(), mmf_invert_format_to_mmf(raw->format()))) {
|
||||
mmf_del_venc_channel(kvm_venc.mmf_venc_chn);
|
||||
kvm_venc.enc_h264_init = 0;
|
||||
// rtmp->unlock();
|
||||
debug("[kvmv]mmf venc push failed!\n");
|
||||
// err::check_raise(err::ERR_RUNTIME, "mmf venc push failed!\r\n");
|
||||
return -1;
|
||||
}
|
||||
// log::info("push(): %d \r\n", (int)(time::time_ms() - start_time));
|
||||
if (mmf_venc_pop(kvm_venc.mmf_venc_chn, &_stream)) {
|
||||
// log::error("mmf_venc_pop failed\n");
|
||||
mmf_venc_free(kvm_venc.mmf_venc_chn);
|
||||
mmf_del_venc_channel(kvm_venc.mmf_venc_chn);
|
||||
kvm_venc.enc_h264_init = 0;
|
||||
debug("[kvmv]mmf venc push failed!\n");
|
||||
// rtmp->unlock();
|
||||
return -1;
|
||||
}
|
||||
// log::info("pop(): %d \r\n", (int)(time::time_ms() - start_time));
|
||||
ret = h264_stream_dump(ret_stream, &_stream);
|
||||
mmf_venc_free(kvm_venc.mmf_venc_chn);
|
||||
// log::info("dump(): %d \r\n", (int)(time::time_ms() - start_time));
|
||||
quality = type == VENC_H264 ? maxmin_data(10000, 500, quality) : maxmin_data(100, 50, quality);
|
||||
fps = maxmin_data(60, 1, fps);
|
||||
auto codec = static_cast<kvm_mmf::Codec>(type);
|
||||
kvm_mmf::Frame frame;
|
||||
int result = kvm_mmf::acquire(codec, width, height, quality, fps,
|
||||
__atomic_load_n(&kvmvenc_gop, __ATOMIC_ACQUIRE), &frame);
|
||||
if (result < 0) return result;
|
||||
|
||||
// debug("[kvmv]_stream.data[0][4] = %d;\n", _stream.data[0][4]);
|
||||
debug("[kvmv]Frame size = %d;\n", ret_stream->img_data_size);
|
||||
|
||||
if(ret == IMG_H264_TYPE_IF){
|
||||
debug("[kvmv]================ GOP = %d ================\n", kvm_venc.kvm_venc_cfg.gop);
|
||||
debug("[kvmv]SPS; PPS; I-Frame, I-Frame size = %d\n", ret_stream->img_data_size);
|
||||
P_Frame_Count = 0;
|
||||
|
||||
} else if(ret == IMG_H264_TYPE_PF){
|
||||
debug("[kvmv]P-Frame size = %d, P-count = %d\n", ret_stream->img_data_size, P_Frame_Count);
|
||||
P_Frame_Count++;
|
||||
}
|
||||
debug("[kvmv]dump ret = %d\n", ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
void kvmv_init(uint8_t _debug_info_en)
|
||||
{
|
||||
pthread_t thread;
|
||||
pthread_mutex_init(&vi_mutex, NULL);
|
||||
if(_debug_info_en == 0) debug_en = 0;
|
||||
else debug_en = 1;
|
||||
|
||||
// debug("[kvmv]kvmv_init - 1\r\n");
|
||||
|
||||
cam->hmirror(1);
|
||||
cam->vflip(1);
|
||||
cam->restart(default_vpss_width, default_vpss_height, image::FMT_YVU420SP);
|
||||
for(int i = 0; i < kvmv_data_buffer_size; i++){
|
||||
kvmv_data_buffer[i].p_img_data = NULL;
|
||||
}
|
||||
|
||||
kvmv_cfg.try_exit_thread = 0;
|
||||
// debug("[kvmv]kvmv_init - 2\r\n");
|
||||
|
||||
if(kvmv_cfg.thread_is_running == 1){
|
||||
debug("[kvmv]thread is running!\r\n");
|
||||
} else {
|
||||
if (0 != pthread_create(&thread, NULL, vi_subsystem_detection, NULL)) {
|
||||
debug("[kvmv]create vi_subsystem_detection thread failed!\r\n");
|
||||
// return -1;
|
||||
}
|
||||
|
||||
if (0 != pthread_create(&thread, NULL, watchdog_sf_feed, NULL)) {
|
||||
debug("[kvmv]create watchdog_sf_feed thread failed!\r\n");
|
||||
// return -1;
|
||||
}
|
||||
}
|
||||
// debug("[kvmv]kvmv_init - 3\r\n");
|
||||
}
|
||||
|
||||
uint8_t check_kvmv(uint8_t _try_num)
|
||||
{
|
||||
if(__atomic_load_n(&kvmv_cfg.hdmi_cable_state, __ATOMIC_ACQUIRE) == 0){
|
||||
debug("[kvmv]HDMI Cable not exist!\n");
|
||||
return 0;
|
||||
}
|
||||
if(_try_num >= KVMV_MAX_TRY_NUM){
|
||||
debug("[kvmv]try_num >= KVMV_MAX_TRY_NUM!\n");
|
||||
return 0;
|
||||
}
|
||||
// if(sync_vi_res() != 0){
|
||||
if(kvmv_cfg.reopen_cam_flag == 1){
|
||||
// vi size changed
|
||||
kvmv_cfg.reopen_cam_flag = 0;
|
||||
// cam->open(kvmv_cfg.vpss_width, kvmv_cfg.vpss_height, image::FMT_YVU420SP, 3);
|
||||
cam->restart(default_vpss_width, default_vpss_height, image::FMT_YVU420SP);
|
||||
debug("[kvmv]vi size changed, try again\n");
|
||||
return 1;
|
||||
}
|
||||
debug("[kvmv]just try again\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
void set_venc_auto_recyc(uint8_t _enable)
|
||||
{
|
||||
if(_enable) kvmv_cfg.venc_auto_recyc = 1;
|
||||
else kvmv_cfg.venc_auto_recyc = 0;
|
||||
}
|
||||
|
||||
/**********************************************************************************
|
||||
* @name kvmv_read_img
|
||||
* @author Sipeed BuGu
|
||||
* @date 2024/10/25
|
||||
* @version R1.0
|
||||
* @brief Acquire the encoded image with auto init
|
||||
* @param _width @input: Output image width
|
||||
* @param _height @input: Output image height
|
||||
* @param _type @input: Encode type
|
||||
* @param _qlty @input: MJPEG: (50-100) | H264: (500-10000)
|
||||
* @param _pp_kvm_data @output: Encode data
|
||||
* @param _p_kvmv_data_size @output: Encode data size
|
||||
* @return
|
||||
-7: HDMI INPUT RES ERROR
|
||||
-6: Unsupported resolution, please modify it in the host settings.
|
||||
-5: Retrieving image, please wait
|
||||
-4: Modifying image resolution, please wait
|
||||
-3: img buffer full
|
||||
-2: VENC Errorl
|
||||
-1: No images were acquired
|
||||
0: Acquire MJPEG encoded images
|
||||
1: Acquire H264 encoded images(SPS)[Deprecated]
|
||||
2: Acquire H264 encoded images(PPS)[Deprecated]
|
||||
3: Acquire H264 encoded images(I)
|
||||
4: Acquire H264 encoded images(P)
|
||||
5: IMG not changed
|
||||
**********************************************************************************/
|
||||
int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _qlty, uint8_t** _pp_kvm_data, uint32_t* _p_kvmv_data_size)
|
||||
{
|
||||
*_pp_kvm_data = NULL;
|
||||
*_p_kvmv_data_size = 0;
|
||||
static uint8_t frame_undetact_count = 0;
|
||||
// uint64_t __attribute__((unused)) start_time = time::time_ms();
|
||||
debug("[kvmv]kvmv_read_img type = %d...\n", _type);
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_REALTIME, &ts);
|
||||
ts.tv_sec += 1;
|
||||
// pthread_mutex_lock(&vi_mutex); // Add lock
|
||||
int mutex_res = pthread_mutex_timedlock(&vi_mutex, &ts);
|
||||
if(mutex_res != 0){
|
||||
return -5;
|
||||
}
|
||||
if (kvmv_cfg.hdmi_res_err == ERROR_RES){
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return -7;
|
||||
}
|
||||
if (kvmv_cfg.hdmi_res_type == UNSUPPORT_RES){
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return -6;
|
||||
}
|
||||
if (kvmv_cfg.vi_detect_state == 1){
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return -4;
|
||||
}
|
||||
uint8_t try_num = 0;
|
||||
do {
|
||||
if(kvmv_cfg.vpss_width != _width || kvmv_cfg.vpss_height != _height){
|
||||
if(_width == 0 || _height == 0){
|
||||
// Follow the HDMI output
|
||||
kvmv_cfg.vpss_width = kvmv_cfg.vi_width;
|
||||
kvmv_cfg.vpss_height = kvmv_cfg.vi_height;
|
||||
|
||||
if(kvmv_cfg.Auto_res == 0){
|
||||
kvmv_cfg.Auto_res = 1;
|
||||
cam->set_resolution(kvmv_cfg.vpss_width, kvmv_cfg.vpss_height);
|
||||
kvmv_cfg.reinit_flag = 1;
|
||||
}
|
||||
|
||||
} else {
|
||||
kvmv_cfg.Auto_res = 0;
|
||||
// Set the output
|
||||
kvmv_cfg.vpss_width = _width;
|
||||
kvmv_cfg.vpss_height = _height;
|
||||
|
||||
cam->set_resolution(kvmv_cfg.vpss_width, kvmv_cfg.vpss_height);
|
||||
kvmv_cfg.reinit_flag = 1;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
if (kvmv_cfg.reinit_flag == 1) {
|
||||
cam->hmirror(1);
|
||||
cam->vflip(1);
|
||||
kvmv_cfg.reinit_flag = 0;
|
||||
}
|
||||
// debug("[kvmv]befor read img: %d \r\n", (int)(time::time_ms() - start_time));
|
||||
image::Image *img = cam->read();
|
||||
// debug("[kvmv]read img: %d \r\n", (int)(time::time_ms() - start_time));
|
||||
|
||||
if(img != NULL){
|
||||
if(kvmv_cfg.fresh_frame_count != 0){
|
||||
// Do not restart VI after HDMI idle. Reopening the MMF
|
||||
// channel can exhaust the carveout heap when the detector
|
||||
// thread is also transitioning. Consume queued frames
|
||||
// instead; the camera buffer contains at most three frames.
|
||||
kvmv_cfg.fresh_frame_count--;
|
||||
delete img;
|
||||
continue;
|
||||
}
|
||||
|
||||
// frame detect
|
||||
if(_type == VENC_MJPEG && kvmv_cfg.frame_detact != 0){
|
||||
if(kvmv_cfg.stream_stop == 0){
|
||||
frame_undetact_count++;
|
||||
} else {
|
||||
frame_undetact_count = kvmv_cfg.frame_detact;
|
||||
}
|
||||
|
||||
if(frame_undetact_count == kvmv_cfg.frame_detact){
|
||||
frame_undetact_count = 0;
|
||||
|
||||
if(frame_changed(img) == 0){
|
||||
debug("[kvmv]frame not changed...\n");
|
||||
kvmv_cfg.stream_stop = 1;
|
||||
delete img;
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return 5;
|
||||
} else {
|
||||
kvmv_cfg.stream_stop = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
delete img;
|
||||
debug("[kvmv]can`t get img...\n");
|
||||
continue;
|
||||
// pthread_mutex_unlock(&vi_mutex);
|
||||
// return IMG_NOT_EXIST;
|
||||
}
|
||||
// debug("[kvmv]cheak img null?: %d \r\n", (int)(time::time_ms() - start_time));
|
||||
|
||||
// img exist
|
||||
// Encode
|
||||
if(kvmv_cfg.venc_type == VENC_MJPEG && kvmv_cfg.venc_type != _type){
|
||||
if(kvmv_cfg.venc_auto_recyc == 1){
|
||||
mmf_enc_jpg_deinit(0);
|
||||
}
|
||||
kvm_venc.enc_h264_init = 1;
|
||||
}
|
||||
if(kvmv_cfg.venc_type == VENC_H264 && kvmv_cfg.venc_type != _type){
|
||||
if(kvmv_cfg.venc_auto_recyc == 1){
|
||||
mmf_del_venc_channel(kvm_venc.mmf_venc_chn);
|
||||
}
|
||||
kvm_venc.enc_h264_init = 0;
|
||||
}
|
||||
|
||||
kvmv_cfg.venc_type = _type;
|
||||
|
||||
if(kvmv_cfg.venc_type == VENC_MJPEG){
|
||||
kvmv_data_t* p_kvmv_data = get_save_buffer();
|
||||
if(p_kvmv_data == NULL){
|
||||
// buffer full
|
||||
delete img;
|
||||
debug("[kvmv]jpg buffer full\n");
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return IMG_BUFFER_FULL;
|
||||
}
|
||||
image::Image *jpg = img->to_jpeg(maxmin_data(99, 51, (int)_qlty));
|
||||
if(jpg == NULL || !jpg_dump(p_kvmv_data, jpg)){
|
||||
delete jpg;
|
||||
delete img;
|
||||
debug("[kvmv]failed to allocate jpg buffer\n");
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return IMG_BUFFER_FULL;
|
||||
}
|
||||
delete jpg;
|
||||
delete img;
|
||||
*_pp_kvm_data = p_kvmv_data->p_img_data;
|
||||
*_p_kvmv_data_size = p_kvmv_data->img_data_size;
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return IMG_MJPEG_TYPE;
|
||||
} else if (kvmv_cfg.venc_type == VENC_H264){
|
||||
int ret;
|
||||
kvmv_data_t* p_kvmv_data = get_save_buffer();
|
||||
if(p_kvmv_data == NULL){
|
||||
// buffer full
|
||||
delete img;
|
||||
*_pp_kvm_data = NULL;
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return IMG_BUFFER_FULL;
|
||||
}
|
||||
// debug("[kvmv]get_save_buffer: %d \r\n", (int)(time::time_ms() - start_time));
|
||||
ret = raw_to_h264(img, p_kvmv_data, maxmin_data(10000, 500, (int)_qlty));
|
||||
// debug("[kvmv]venc raw_to_h264: %d \r\n", (int)(time::time_ms() - start_time));
|
||||
delete img;
|
||||
*_pp_kvm_data = p_kvmv_data->p_img_data;
|
||||
*_p_kvmv_data_size = p_kvmv_data->img_data_size;
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return ret;
|
||||
}
|
||||
} while (check_kvmv(try_num++));
|
||||
// debug("[kvmv]return: %d \r\n", (int)(time::time_ms() - start_time));
|
||||
*_pp_kvm_data = NULL;
|
||||
pthread_mutex_unlock(&vi_mutex);
|
||||
return IMG_NOT_EXIST;
|
||||
}
|
||||
|
||||
int free_kvmv_data(uint8_t ** _pp_kvm_data)
|
||||
{
|
||||
// debug("[kvmv]free_kvmv_data - 1\r\n");
|
||||
for(int i = 0; i < kvmv_data_buffer_size; i++){
|
||||
if(*_pp_kvm_data == kvmv_data_buffer[i].p_img_data){
|
||||
// debug("[kvmv]free buffer : %d\n", *_pp_kvm_data);
|
||||
if (*_pp_kvm_data != NULL){
|
||||
// debug("[kvmv]free_kvmv_data - 2\r\n");
|
||||
free(*_pp_kvm_data);
|
||||
// debug("[kvmv]free_kvmv_data - 3\r\n");
|
||||
kvmv_data_buffer[i].p_img_data = NULL;
|
||||
uint8_t _type = kvmv_data_buffer[i].img_data_type;
|
||||
return _type;
|
||||
} else {
|
||||
return IMG_NOT_EXIST;
|
||||
// Detect unchanged JPEGs without reading raw VPSS pixels.
|
||||
if (codec == kvm_mmf::MJPEG) {
|
||||
uint8_t interval = kvmv_cfg.frame_detact;
|
||||
if (interval == 0) {
|
||||
jpeg_detection = {};
|
||||
} else if (jpeg_detection.unchanged || ++jpeg_detection.count >= interval) {
|
||||
jpeg_detection.count = 0;
|
||||
uint64_t hash = 14695981039346656037ULL;
|
||||
for (uint32_t i = 0; i < frame.size; ++i) hash = (hash ^ frame.data[i]) * 1099511628211ULL;
|
||||
jpeg_detection.unchanged = hash == jpeg_detection.hash;
|
||||
jpeg_detection.hash = hash;
|
||||
if (jpeg_detection.unchanged) {
|
||||
kvm_mmf::release(codec, frame.token);
|
||||
free(frame.data);
|
||||
return 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
return IMG_NOT_EXIST;
|
||||
|
||||
*out = {frame.data, frame.size, frame.timestamp, frame.token, type};
|
||||
return codec == kvm_mmf::MJPEG ? IMG_MJPEG_TYPE :
|
||||
(frame.keyframe ? IMG_H264_TYPE_IF : IMG_H264_TYPE_PF);
|
||||
}
|
||||
|
||||
void free_all_kvmv_data()
|
||||
void kvmv_release_frame(kvmv_frame_t *frame)
|
||||
{
|
||||
for(int i = 0; i < kvmv_data_buffer_size; i++){
|
||||
if(kvmv_data_buffer[i].p_img_data != NULL){
|
||||
free(kvmv_data_buffer[i].p_img_data);
|
||||
kvmv_data_buffer[i].p_img_data = NULL;
|
||||
}
|
||||
}
|
||||
if (!frame || !frame->data) return;
|
||||
kvm_mmf::release(static_cast<kvm_mmf::Codec>(frame->codec), frame->token);
|
||||
free(frame->data);
|
||||
*frame = {};
|
||||
}
|
||||
|
||||
void kvmv_stop_stream(uint8_t type)
|
||||
{
|
||||
if (type > VENC_H264) return;
|
||||
std::lock_guard<std::mutex> lock(capture_mutex[type]);
|
||||
kvm_mmf::stop(static_cast<kvm_mmf::Codec>(type));
|
||||
if (type == VENC_MJPEG) jpeg_detection = {};
|
||||
}
|
||||
|
||||
void kvmv_deinit()
|
||||
{
|
||||
set_hdmi_capture_enabled(0);
|
||||
pthread_mutex_destroy(&vi_mutex);
|
||||
kvmv_cfg.try_exit_thread = 1;
|
||||
cam->close();
|
||||
mmf_deinit();
|
||||
free_all_kvmv_data();
|
||||
kvmv_cfg.try_exit_thread = true;
|
||||
if (detection_started) { pthread_join(detection_thread, nullptr); detection_started = false; }
|
||||
if (watchdog_started) { pthread_join(watchdog_thread, nullptr); watchdog_started = false; }
|
||||
std::unique_lock<std::mutex> h264(capture_mutex[VENC_H264], std::defer_lock);
|
||||
std::unique_lock<std::mutex> mjpeg(capture_mutex[VENC_MJPEG], std::defer_lock);
|
||||
std::lock(h264, mjpeg);
|
||||
kvm_mmf::deinitialize();
|
||||
jpeg_detection = {};
|
||||
}
|
||||
|
||||
uint8_t kvmv_hdmi_control(uint8_t _en)
|
||||
{
|
||||
if(_en == 0){
|
||||
set_hdmi_capture_enabled(0);
|
||||
kvm_mmf::stop(kvm_mmf::H264);
|
||||
kvm_mmf::stop(kvm_mmf::MJPEG);
|
||||
}
|
||||
|
||||
if(kvmv_cfg.hw_version == 0){
|
||||
@@ -2030,11 +1546,6 @@ uint8_t kvmv_hdmi_control(uint8_t _en)
|
||||
} else {
|
||||
kvmv_cfg.hdmi_stop_flag = 0;
|
||||
system("echo 1 > /sys/class/gpio/gpio451/value");
|
||||
|
||||
// Keep the existing VI channel and consume queued frames after idle.
|
||||
// Reopening it here or from kvmv_read_img can exhaust the carveout
|
||||
// heap while the HDMI detector is transitioning.
|
||||
kvmv_cfg.fresh_frame_count = fresh_frame_discard_count;
|
||||
set_hdmi_capture_enabled(1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1,66 +1,71 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
list(APPEND ADD_REQUIREMENTS basic ini)
|
||||
list(APPEND ADD_INCLUDE "include")
|
||||
|
||||
# sophgo-middleware
|
||||
set(middleware_src_path "${CMAKE_CURRENT_SOURCE_DIR}/../3rd_party/sophgo-middleware/sophgo-middleware")
|
||||
|
||||
|
||||
set(source_dir "src")
|
||||
append_srcs_dir(ADD_SRCS "${source_dir}")
|
||||
|
||||
# middleware
|
||||
set(middleware_include_dir .
|
||||
${middleware_src_path}/v2/component/panel/${CONFIG_SOPHGO_MIDDLEWARE_CHIP}
|
||||
${middleware_src_path}/v2/include
|
||||
${middleware_src_path}/v2/include/isp/${CONFIG_SOPHGO_MIDDLEWARE_CHIP}
|
||||
${middleware_src_path}/v2/sample/common
|
||||
${middleware_src_path}/v2/uapi
|
||||
${middleware_src_path}/v2/3rdparty/inih
|
||||
${middleware_src_path}/v2/modules/ive/include/
|
||||
)
|
||||
list(APPEND ADD_INCLUDE ${middleware_include_dir})
|
||||
set_property(SOURCE ${middleware_include_dir} PROPERTY GENERATED 1)
|
||||
|
||||
append_srcs_dir(middleware_src_dir ${middleware_src_path}/v2/sample/common
|
||||
${middleware_src_path}/v2/component/isp/sensor/sg200x/lontium_lt6911
|
||||
)
|
||||
list(APPEND ADD_SRCS ${middleware_src_dir}
|
||||
"${source_dir}/kvm_mmf.cpp")
|
||||
set_property(SOURCE ${middleware_src_dir} PROPERTY GENERATED 1)
|
||||
|
||||
set(middleware_static_lib_file "")
|
||||
list(APPEND ADD_STATIC_LIB ${middleware_static_lib_file})
|
||||
set_property(SOURCE ${middleware_static_lib_file} PROPERTY GENERATED 1)
|
||||
|
||||
set(mmf_lib_dir ${middleware_src_path}/v2/lib)
|
||||
|
||||
if(CONFIG_SOPHGO_MIDDLEWARE_C_LIBRARY STREQUAL "musl")
|
||||
set(middleware_dynamic_lib_file ${mmf_lib_dir}/libcvi_bin.so
|
||||
${mmf_lib_dir}/libae.so
|
||||
${mmf_lib_dir}/libaf.so
|
||||
${mmf_lib_dir}/libawb.so
|
||||
${mmf_lib_dir}/libcvi_bin_isp.so
|
||||
${mmf_lib_dir}/libisp_algo.so
|
||||
${mmf_lib_dir}/libisp.so
|
||||
${mmf_lib_dir}/libsys.so
|
||||
${mmf_lib_dir}/libvdec.so
|
||||
${mmf_lib_dir}/libvenc.so
|
||||
${mmf_lib_dir}/libvpu.so
|
||||
${mmf_lib_dir}/3rd/libini.so)
|
||||
elseif(CONFIG_SOPHGO_MIDDLEWARE_C_LIBRARY STREQUAL "glibc")
|
||||
set(middleware_dynamic_lib_file ${mmf_lib_dir}/libcvi_vb.so
|
||||
${mmf_lib_dir}/libcvi_sys.so
|
||||
${mmf_lib_dir}/libcvi_debug.so
|
||||
)
|
||||
else()
|
||||
message(FATAL_ERROR "No sophgo middleware c library support, please check menuoconfig to select c library")
|
||||
if(COMMAND register_component)
|
||||
if(NOT EXISTS "$ENV{KVM_MMF_PREFIX}/lib/libkvm_mmf.so")
|
||||
message(FATAL_ERROR "Build standalone kvm_mmf first and set KVM_MMF_PREFIX (support/sg2002/build does this)")
|
||||
endif()
|
||||
list(APPEND ADD_INCLUDE "$ENV{KVM_MMF_PREFIX}/include")
|
||||
file(GLOB video_libraries "$ENV{KVM_MMF_PREFIX}/lib/*.so")
|
||||
list(APPEND ADD_DYNAMIC_LIB ${video_libraries})
|
||||
register_component()
|
||||
return()
|
||||
endif()
|
||||
list(APPEND ADD_DYNAMIC_LIB ${middleware_dynamic_lib_file})
|
||||
set_property(SOURCE ${middleware_dynamic_lib_file} PROPERTY GENERATED 1)
|
||||
|
||||
list(APPEND ADD_DEFINITIONS_PRIVATE -DSENSOR_LONTIUM_LT6911
|
||||
-DSENSOR2_TYPE=LONTIUM_LT6911_2M_60FPS_8BIT)
|
||||
project(kvm_mmf VERSION 1.0 LANGUAGES C CXX)
|
||||
include(GNUInstallDirs)
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
set(SOPHGO_MIDDLEWARE_DIR "" CACHE PATH "CVI SDK middleware root containing v2/")
|
||||
set(SOPHGO_RUNTIME_DIR "" CACHE PATH "Firmware-matched SDK .so directory; defaults to middleware v2/lib")
|
||||
set(SOPHGO_CHIP cv181x CACHE STRING "CVI SDK chip family")
|
||||
option(KVM_MMF_INSTALL_SDK_LIBS "Install the SDK runtime libraries alongside kvm_mmf" ON)
|
||||
if(NOT EXISTS "${SOPHGO_MIDDLEWARE_DIR}/v2/include/cvi_venc.h")
|
||||
message(FATAL_ERROR "Set SOPHGO_MIDDLEWARE_DIR to the CVI SDK middleware root containing v2/include/cvi_venc.h")
|
||||
endif()
|
||||
get_filename_component(middleware "${SOPHGO_MIDDLEWARE_DIR}/v2" REALPATH)
|
||||
if(SOPHGO_RUNTIME_DIR)
|
||||
get_filename_component(runtime "${SOPHGO_RUNTIME_DIR}" REALPATH)
|
||||
else()
|
||||
set(runtime "${middleware}/lib")
|
||||
endif()
|
||||
set(common "${middleware}/sample/common")
|
||||
set(sensor "${middleware}/component/isp/sensor/sg200x/lontium_lt6911")
|
||||
|
||||
register_component(DYNAMIC)
|
||||
add_library(kvm_mmf SHARED
|
||||
src/kvm_mmf.cpp
|
||||
"${common}/sample_common_sys.c"
|
||||
"${common}/sample_common_vi.c"
|
||||
"${common}/sample_common_isp.c"
|
||||
"${common}/sample_common_bin.c"
|
||||
"${common}/sample_common_platform.c"
|
||||
"${common}/sample_common_sensor.c"
|
||||
"${sensor}/lt6911_cmos.c" "${sensor}/lt6911_sensor_ctl.c"
|
||||
)
|
||||
target_compile_features(kvm_mmf PRIVATE cxx_std_17)
|
||||
target_compile_definitions(kvm_mmf PRIVATE SENSOR_LONTIUM_LT6911 SENSOR2_TYPE=LONTIUM_LT6911_2M_60FPS_8BIT)
|
||||
target_compile_options(kvm_mmf PRIVATE -Wall -ffunction-sections -fdata-sections)
|
||||
target_include_directories(kvm_mmf PUBLIC
|
||||
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
|
||||
)
|
||||
target_include_directories(kvm_mmf PRIVATE
|
||||
"${common}" "${middleware}/include" "${middleware}/uapi"
|
||||
"${middleware}/include/isp/${SOPHGO_CHIP}"
|
||||
"${middleware}/component/panel/${SOPHGO_CHIP}"
|
||||
"${middleware}/3rdparty/inih"
|
||||
)
|
||||
foreach(name cvi_bin ae af awb cvi_bin_isp isp isp_algo sys venc vpu ini)
|
||||
unset(sdk_${name} CACHE)
|
||||
find_library(sdk_${name} NAMES ${name} PATHS "${runtime}" "${runtime}/3rd" NO_DEFAULT_PATH REQUIRED)
|
||||
target_link_libraries(kvm_mmf PRIVATE "${sdk_${name}}")
|
||||
if(KVM_MMF_INSTALL_SDK_LIBS AND sdk_${name} MATCHES "\\.so$")
|
||||
install(FILES "${sdk_${name}}" DESTINATION ${CMAKE_INSTALL_LIBDIR})
|
||||
endif()
|
||||
endforeach()
|
||||
target_link_libraries(kvm_mmf PRIVATE Threads::Threads m atomic)
|
||||
target_link_options(kvm_mmf PRIVATE -Wl,--as-needed -Wl,--gc-sections -Wl,-z,defs -Wl,--no-allow-shlib-undefined)
|
||||
set_target_properties(kvm_mmf PROPERTIES
|
||||
C_VISIBILITY_PRESET hidden CXX_VISIBILITY_PRESET hidden VISIBILITY_INLINES_HIDDEN YES
|
||||
BUILD_WITH_INSTALL_RPATH YES INSTALL_RPATH "\$ORIGIN")
|
||||
install(TARGETS kvm_mmf LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR})
|
||||
install(FILES include/kvm_mmf.hpp DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
set(CMAKE_SYSTEM_NAME Linux)
|
||||
set(CMAKE_SYSTEM_PROCESSOR riscv64)
|
||||
|
||||
set(CMAKE_C_COMPILER riscv64-unknown-linux-musl-gcc)
|
||||
set(CMAKE_CXX_COMPILER riscv64-unknown-linux-musl-g++)
|
||||
set(CMAKE_C_FLAGS_INIT "-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d")
|
||||
set(CMAKE_CXX_FLAGS_INIT "${CMAKE_C_FLAGS_INIT}")
|
||||
@@ -1,71 +1,27 @@
|
||||
#ifndef __KVM_MMF_HPP__
|
||||
#define __KVM_MMF_HPP__
|
||||
#pragma once
|
||||
|
||||
#include "stdint.h"
|
||||
#include <stdint.h>
|
||||
|
||||
typedef struct {
|
||||
uint8_t *data[8];
|
||||
int data_size[8];
|
||||
int count;
|
||||
} mmf_stream_t;
|
||||
// VI -> VPSS -> VENC, with no application queue of encoded frames.
|
||||
namespace kvm_mmf __attribute__((visibility("default"))) {
|
||||
|
||||
typedef struct {
|
||||
uint8_t type; // 0, jpg; 1, h265; 2, h264
|
||||
int w;
|
||||
int h;
|
||||
int fmt;
|
||||
uint8_t jpg_quality; // jpeg
|
||||
int gop; // h264
|
||||
int intput_fps; // h264
|
||||
int output_fps; // h264
|
||||
int bitrate; // h264
|
||||
} mmf_venc_cfg_t;
|
||||
enum Codec { MJPEG = 0, H264 = 1 };
|
||||
|
||||
// init sys
|
||||
int mmf_init(void);
|
||||
int mmf_deinit(void);
|
||||
int mmf_try_deinit(bool force);
|
||||
bool mmf_is_init(void);
|
||||
struct Frame {
|
||||
uint8_t *data = nullptr;
|
||||
uint32_t size = 0;
|
||||
uint64_t timestamp = 0; // SDK capture PTS, in microseconds.
|
||||
uint64_t token = 0;
|
||||
bool keyframe = false;
|
||||
};
|
||||
|
||||
// manage vi channels(vi->vpssgroup->vpss->frame)
|
||||
int mmf_get_vi_unused_channel(void);
|
||||
int mmf_vi_init(void);
|
||||
int mmf_vi_deinit(void);
|
||||
int mmf_add_vi_channel_with_enc(int ch, int width, int height, int format);
|
||||
int mmf_add_vi_channel(int ch, int width, int height, int format);
|
||||
int mmf_del_vi_channel(int ch);
|
||||
int mmf_del_vi_channel_all(void);
|
||||
int mmf_reset_vi_channel(int ch, int width, int height, int format);
|
||||
bool mmf_vi_chn_is_open(int ch);
|
||||
int mmf_vi_aligned_width(int ch);
|
||||
void mmf_set_vi_hmirror(int ch, bool en);
|
||||
void mmf_set_vi_vflip(int ch, bool en);
|
||||
void mmf_get_vi_hmirror(int ch, bool *en);
|
||||
void mmf_get_vi_vflip(int ch, bool *en);
|
||||
bool initialize();
|
||||
bool restart();
|
||||
void deinitialize();
|
||||
void stop(Codec codec);
|
||||
|
||||
// get vi frame
|
||||
int mmf_vi_frame_pop(int ch, void **data, int *len, int *width, int *height, int *format);
|
||||
void mmf_vi_frame_free(int ch);
|
||||
// Release the current VI frame immediately. mmf_vi_frame_free() defers
|
||||
// release so the frame can be sent directly to VENC without a second copy.
|
||||
void mmf_vi_frame_release(int ch);
|
||||
int acquire(Codec codec, uint16_t width, uint16_t height, uint16_t quality,
|
||||
uint16_t fps, uint8_t gop, Frame *frame);
|
||||
void release(Codec codec, uint64_t token);
|
||||
|
||||
// invert format
|
||||
int mmf_invert_format_to_maix(int mmf_format);
|
||||
int mmf_invert_format_to_mmf(int maix_format);
|
||||
|
||||
// venc
|
||||
int mmf_enc_jpg_init(int ch, int w, int h, int format, int quality);
|
||||
int mmf_enc_jpg_deinit(int ch);
|
||||
int mmf_enc_jpg_push(int ch, uint8_t *data, int w, int h, int format);
|
||||
int mmf_enc_jpg_push_with_quality(int ch, uint8_t *data, int w, int h, int format, int quality);
|
||||
int mmf_enc_jpg_pop(int ch, uint8_t **data, int *size);
|
||||
int mmf_enc_jpg_free(int ch);
|
||||
int mmf_add_venc_channel(int ch, mmf_venc_cfg_t *cfg);
|
||||
int mmf_del_venc_channel(int ch);
|
||||
int mmf_del_venc_channel_all();
|
||||
int mmf_venc_push(int ch, uint8_t *data, int w, int h, int format);
|
||||
int mmf_venc_pop(int ch, mmf_stream_t *stream);
|
||||
int mmf_venc_free(int ch);
|
||||
|
||||
#endif // __KVM_MMF_HPP__
|
||||
} // namespace kvm_mmf
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ maixcdk_build() {
|
||||
|
||||
DIST_PATH="./dist"
|
||||
rm -rf $DIST_PATH
|
||||
|
||||
|
||||
if [ "$input_value" = "from_zero" ]
|
||||
then
|
||||
maixcdk build <<EOF
|
||||
@@ -24,8 +24,31 @@ EOF
|
||||
fi
|
||||
}
|
||||
|
||||
build_kvm_mmf() {
|
||||
local component="$NanoKVM_PATH/support/sg2002/additional/kvm_mmf"
|
||||
local middleware="${SOPHGO_MIDDLEWARE_DIR:-$MAIXCDK_PATH/components/3rd_party/sophgo-middleware/sophgo-middleware}"
|
||||
local runtime="${SOPHGO_RUNTIME_DIR:-$NanoKVM_PATH/server/dl_lib}"
|
||||
local build_dir="$Project_PATH/build/kvm_mmf-build"
|
||||
export KVM_MMF_PREFIX="$Project_PATH/build/kvm_mmf-install"
|
||||
cmake -S "$component" -B "$build_dir" \
|
||||
-DCMAKE_TOOLCHAIN_FILE="$component/cmake/riscv64-musl.cmake" \
|
||||
-DSOPHGO_MIDDLEWARE_DIR="$middleware" -DSOPHGO_RUNTIME_DIR="$runtime" \
|
||||
-DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$KVM_MMF_PREFIX" || return 1
|
||||
cmake --build "$build_dir" --parallel || return 1
|
||||
cmake --install "$build_dir" || return 1
|
||||
}
|
||||
|
||||
update_components() {
|
||||
cp -rf "$NanoKVM_PATH/support/sg2002/additional/kvm" "$MAIXCDK_PATH/components/" || return 1
|
||||
cp -rf "$NanoKVM_PATH/support/sg2002/additional/kvm_mmf" "$MAIXCDK_PATH/components/" || return 1
|
||||
cp -f "$NanoKVM_PATH/support/sg2002/additional/peripheral/port/maixcam/maix_i2c.cpp" \
|
||||
"$MAIXCDK_PATH/components/peripheral/port/maixcam/maix_i2c.cpp" || return 1
|
||||
cp -f "$NanoKVM_PATH/support/sg2002/additional/sophgo-middleware/v2/sample/common/sample_common_sensor.c" \
|
||||
"$MAIXCDK_PATH/components/3rd_party/sophgo-middleware/sophgo-middleware/v2/sample/common/" || return 1
|
||||
}
|
||||
|
||||
chack_build(){
|
||||
DIST_PATH="./dist"
|
||||
DIST_PATH="./dist"
|
||||
if [ -d "$DIST_PATH" ]
|
||||
then
|
||||
echo "SUCCESS"
|
||||
@@ -41,15 +64,7 @@ maixcdk_clean(){
|
||||
|
||||
case "$1" in
|
||||
update_lib)
|
||||
# This script does not run under "set -e", and a release build relies on
|
||||
# update_lib to guarantee the SDK components match the source tree. A copy
|
||||
# that fails silently here would produce stale libraries, so fail loudly.
|
||||
set -e
|
||||
cp -rf $NanoKVM_PATH/support/sg2002/additional/kvm $MAIXCDK_PATH/components
|
||||
cp -rf $NanoKVM_PATH/support/sg2002/additional/kvm_mmf $MAIXCDK_PATH/components
|
||||
cp -rf $NanoKVM_PATH/support/sg2002/additional/vision $MAIXCDK_PATH/components
|
||||
cp -f $NanoKVM_PATH/support/sg2002/additional/peripheral/port/maixcam/maix_i2c.cpp $MAIXCDK_PATH/components/peripheral/port/maixcam/maix_i2c.cpp
|
||||
cp -f $NanoKVM_PATH/support/sg2002/additional/sophgo-middleware/v2/sample/common/sample_common_sensor.c $MAIXCDK_PATH/components/3rd_party/sophgo-middleware/sophgo-middleware/v2/sample/common/
|
||||
update_components || exit 1
|
||||
|
||||
echo -e "\e[36m[build] \e[32mupdate_lib ok!\e[0m"
|
||||
exit 0
|
||||
@@ -106,37 +121,34 @@ then
|
||||
if [ ! -e "$MAIXCDK_PATH/components/kvm" ]
|
||||
then
|
||||
echo -e "\e[36m[build] \e[32mDetected that the library has not been modified; first, move the library\e[0m"
|
||||
|
||||
|
||||
mkdir $NanoKVM_PATH/support/sg2002/additional/original
|
||||
mv -f $MAIXCDK_PATH/components/vision $NanoKVM_PATH/support/sg2002/additional/original
|
||||
# mv $MAIXCDK_PATH/components/maixcam_lib $NanoKVM_PATH/support/sg2002/additional/original
|
||||
mv -f $MAIXCDK_PATH/components/peripheral/port/maixcam/maix_i2c.cpp $NanoKVM_PATH/support/sg2002/additional/original
|
||||
mv -f $MAIXCDK_PATH/components/3rd_party/sophgo-middleware/sophgo-middleware/v2/sample/common/sample_common_sensor.c $NanoKVM_PATH/support/sg2002/additional/original
|
||||
|
||||
cp -rf $NanoKVM_PATH/support/sg2002/additional/kvm $MAIXCDK_PATH/components
|
||||
cp -rf $NanoKVM_PATH/support/sg2002/additional/kvm_mmf $MAIXCDK_PATH/components
|
||||
cp -rf $NanoKVM_PATH/support/sg2002/additional/vision $MAIXCDK_PATH/components
|
||||
cp -f $NanoKVM_PATH/support/sg2002/additional/peripheral/port/maixcam/maix_i2c.cpp $MAIXCDK_PATH/components/peripheral/port/maixcam/maix_i2c.cpp
|
||||
cp -f $NanoKVM_PATH/support/sg2002/additional/sophgo-middleware/v2/sample/common/sample_common_sensor.c $MAIXCDK_PATH/components/3rd_party/sophgo-middleware/sophgo-middleware/v2/sample/common/
|
||||
|
||||
echo -e "\e[36m[build] \e[32mMove the library completed!\e[0m"
|
||||
fi
|
||||
|
||||
cd $Project_PATH
|
||||
if [ -f $JSON_PATH ]
|
||||
update_components || exit 1
|
||||
|
||||
cd "$Project_PATH"
|
||||
if [ -f "$JSON_PATH" ] && ! grep -q "$Platform_Char" "$JSON_PATH"; then
|
||||
maixcdk_clean || exit 1
|
||||
fi
|
||||
|
||||
if [ "$Project_Name" = "kvm_vision" ]; then
|
||||
build_kvm_mmf || exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$JSON_PATH" ]
|
||||
then
|
||||
if grep -q $Platform_Char $JSON_PATH; then
|
||||
maixcdk_build
|
||||
else
|
||||
maixcdk_clean
|
||||
maixcdk_build "from_zero"
|
||||
fi
|
||||
maixcdk_build
|
||||
else
|
||||
maixcdk_build "from_zero"
|
||||
fi
|
||||
|
||||
RES=$(chack_build)
|
||||
|
||||
|
||||
if [ "$RES" = "SUCCESS" ]
|
||||
then
|
||||
echo -e "\e[36m[build] \e[32mBuild completed! Use \e[34m./build $Project_Name add_to_kvmapp\e[32m to add it to kvmapp.\e[0m"
|
||||
@@ -173,7 +185,7 @@ then
|
||||
else
|
||||
echo -e "\e[36m[build] \e[32mkvm_vision has been moved to kvmapp.\e[0m"
|
||||
fi
|
||||
|
||||
|
||||
else
|
||||
echo -e "\e[36m[build] \e[31mThe source file does not exist. Please compile using \e[34m./build kvm_system \e[31mfirst.\e[0m"
|
||||
exit 1
|
||||
|
||||
@@ -10,14 +10,14 @@ using namespace maix::sys;
|
||||
extern kvm_sys_state_t kvm_sys_state;
|
||||
extern kvm_oled_state_t kvm_oled_state;
|
||||
|
||||
static void write_hdmi_state(uint8_t active)
|
||||
static bool write_hdmi_state(uint8_t active)
|
||||
{
|
||||
char temp_path[] = "/kvmapp/kvm/.state.init.XXXXXX";
|
||||
const char *state = active != 0 ? "1\n" : "0\n";
|
||||
int fd = mkstemp(temp_path);
|
||||
if(fd < 0){
|
||||
printf("failed to create HDMI state file: %d\n", errno);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
int failure = 0;
|
||||
@@ -40,6 +40,7 @@ static void write_hdmi_state(uint8_t active)
|
||||
unlink(temp_path);
|
||||
printf("failed to publish HDMI state file: %d\n", failure);
|
||||
}
|
||||
return failure == 0;
|
||||
}
|
||||
|
||||
uint8_t get_hdmi_version()
|
||||
@@ -88,38 +89,45 @@ void Production_testing_patch(void)
|
||||
system("sync");
|
||||
}
|
||||
|
||||
void new_app_init(void)
|
||||
static bool init_command(const char *command)
|
||||
{
|
||||
if(system(command) == 0) return true;
|
||||
fprintf(stderr, "[kvms] migration failed: %s\n", command);
|
||||
return false;
|
||||
}
|
||||
|
||||
AppInitResult new_app_init(void)
|
||||
{
|
||||
// Update the necessary scripts
|
||||
system("rm -f /boot/logo.jpeg");
|
||||
system("cp -f /kvmapp/system/update-nanokvm.py /etc/kvm/");
|
||||
if(!init_command("mkdir -p /etc/kvm /kvmapp/kvm")) return APP_INIT_FAILED;
|
||||
if(!init_command("cp -f /kvmapp/system/update-nanokvm.py /etc/kvm/")) return APP_INIT_FAILED;
|
||||
system("rm -f /etc/init.d/S02udisk");
|
||||
system("cp -f /kvmapp/system/init.d/S00kmod /etc/init.d/");
|
||||
system("cp -f /kvmapp/system/init.d/S01fs /etc/init.d/");
|
||||
system("cp -f /kvmapp/system/init.d/S03usbdev /etc/init.d/");
|
||||
system("cp -f /kvmapp/system/init.d/S15kvmhwd /etc/init.d/");
|
||||
system("cp -f /kvmapp/system/init.d/S30eth /etc/init.d/");
|
||||
system("cp -f /kvmapp/system/init.d/S50sshd /etc/init.d/");
|
||||
if(!init_command("cp -f /kvmapp/system/init.d/S00kmod /etc/init.d/")) return APP_INIT_FAILED;
|
||||
if(!init_command("cp -f /kvmapp/system/init.d/S01fs /etc/init.d/")) return APP_INIT_FAILED;
|
||||
if(!init_command("cp -f /kvmapp/system/init.d/S03usbdev /etc/init.d/")) return APP_INIT_FAILED;
|
||||
if(!init_command("cp -f /kvmapp/system/init.d/S15kvmhwd /etc/init.d/")) return APP_INIT_FAILED;
|
||||
if(!init_command("cp -f /kvmapp/system/init.d/S30eth /etc/init.d/")) return APP_INIT_FAILED;
|
||||
if(!init_command("cp -f /kvmapp/system/init.d/S50sshd /etc/init.d/")) return APP_INIT_FAILED;
|
||||
if(kvm_wifi_exist()) {
|
||||
system("cp -f /kvmapp/system/init.d/S30wifi /etc/init.d/");
|
||||
if(!init_command("cp -f /kvmapp/system/init.d/S30wifi /etc/init.d/")) return APP_INIT_FAILED;
|
||||
} else {
|
||||
system("rm -f /etc/init.d/S30wifi");
|
||||
}
|
||||
|
||||
// if exit /etc/init.d/S98tailscaled then cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/
|
||||
if(access("/etc/init.d/S98tailscaled", F_OK) == 0){
|
||||
system("cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/");
|
||||
if(!init_command("cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/")) return APP_INIT_FAILED;
|
||||
}
|
||||
|
||||
// rmmod soph_saradc
|
||||
system("rmmod soph_saradc");
|
||||
system("rm -f /mnt/system/ko/soph_saradc.ko");
|
||||
|
||||
// PCIe Patch
|
||||
// system("cp /kvmapp/system/init.d/S95nanokvm /etc/init.d/");
|
||||
if(access("/kvmapp/jpg_stream/dl_lib/libmaixcam_lib.so", F_OK) != 0){
|
||||
system("cp -f /kvmapp/system/init.d/S95nanokvm /etc/init.d/");
|
||||
}
|
||||
// Keep an already running legacy shell on its original script inode.
|
||||
if(!init_command("cp /kvmapp/system/init.d/S95nanokvm /etc/init.d/.S95nanokvm.new && "
|
||||
"chmod 755 /etc/init.d/.S95nanokvm.new && "
|
||||
"mv -f /etc/init.d/.S95nanokvm.new /etc/init.d/S95nanokvm")) return APP_INIT_FAILED;
|
||||
|
||||
// Remove unnecessary components to speed up boot time
|
||||
system("rm -f /etc/init.d/S04backlight");
|
||||
@@ -129,91 +137,45 @@ void new_app_init(void)
|
||||
system("rm -f /etc/init.d/S99*");
|
||||
|
||||
// Add necessary configuration files for program execution
|
||||
system("mkdir /kvmapp/kvm");
|
||||
system("mkdir /etc/kvm");
|
||||
system("echo 0 > /kvmapp/kvm/now_fps");
|
||||
system("echo 30 > /kvmapp/kvm/fps");
|
||||
system("echo 2000 > /kvmapp/kvm/qlty");
|
||||
system("echo 720 > /kvmapp/kvm/res");
|
||||
system("echo h264 > /kvmapp/kvm/type");
|
||||
write_hdmi_state(0);
|
||||
system("touch /etc/kvm/frame_detact");
|
||||
if(!init_command("echo 0 > /kvmapp/kvm/now_fps && echo 30 > /kvmapp/kvm/fps && "
|
||||
"echo 2000 > /kvmapp/kvm/qlty && echo h264 > /kvmapp/kvm/type && "
|
||||
"touch /etc/kvm/frame_detact")) return APP_INIT_FAILED;
|
||||
if(!write_hdmi_state(0)) return APP_INIT_FAILED;
|
||||
|
||||
// rm jpg_stream & kvm_stream
|
||||
system("rm -rf /kvmapp/jpg_stream");
|
||||
system("rm -f /kvmapp/kvm_system/kvm_stream"); // Cannot delete temporarily; the old production test script uses this file to determine if the download is complete
|
||||
|
||||
system("rm -f /kvmapp/kvm_new_app");
|
||||
system("sync");
|
||||
// system("/etc/init.d/S95nanokvm restart");
|
||||
|
||||
// update ko
|
||||
FILE *fp;
|
||||
uint8_t RW_Data_0[30];
|
||||
uint8_t RW_Data_1[30];
|
||||
fp = popen("md5sum /mnt/system/ko/soph_mipi_rx.ko | grep 086ed01749188975afaa40fb569374f8 | awk '{print $2}'", "r");
|
||||
if ( NULL == fp )
|
||||
{
|
||||
pclose(fp);
|
||||
// return;
|
||||
}
|
||||
fgets((char*)RW_Data_0, 10, fp);
|
||||
pclose(fp);
|
||||
fp = popen("md5sum /mnt/system/ko/soph_mipi_rx.ko | grep 69be7eeded3777f750480a5dd5a1aa26 | awk '{print $2}'", "r");
|
||||
if ( NULL == fp )
|
||||
{
|
||||
pclose(fp);
|
||||
// return;
|
||||
}
|
||||
fgets((char*)RW_Data_1, 10, fp);
|
||||
pclose(fp);
|
||||
FILE *fp = popen("md5sum /mnt/system/ko/soph_mipi_rx.ko", "r");
|
||||
if(!fp) return APP_INIT_FAILED;
|
||||
char digest[33] = {};
|
||||
bool digest_read = fscanf(fp, "%32s", digest) == 1;
|
||||
int digest_status = pclose(fp);
|
||||
if(!digest_read || digest_status != 0) return APP_INIT_FAILED;
|
||||
|
||||
int8_t hdmi_ver = -1;
|
||||
|
||||
if(access("/etc/kvm/hdmi_version", F_OK) == 0){
|
||||
uint8_t RW_Data[2];
|
||||
FILE *fp = fopen("/etc/kvm/hdmi_version", "r");
|
||||
fread(RW_Data, sizeof(char), 2, fp);
|
||||
if(!fp) return APP_INIT_FAILED;
|
||||
int version = fgetc(fp);
|
||||
fclose(fp);
|
||||
if(RW_Data[0] == 'c') hdmi_ver = 1;
|
||||
else if(RW_Data[0] == 'u') hdmi_ver = 2;
|
||||
else if(RW_Data[0] == 'd') hdmi_ver = 2;
|
||||
if(version == EOF) return APP_INIT_FAILED;
|
||||
if(version == 'c') hdmi_ver = 1;
|
||||
else if(version == 'u' || version == 'd') hdmi_ver = 2;
|
||||
}
|
||||
|
||||
// system("/etc/init.d/S03usbdev stop_start");
|
||||
create_temp_watchdog();
|
||||
|
||||
if(hdmi_ver == 2){
|
||||
if(RW_Data_1[0] != '/'){
|
||||
system("cp /kvmapp/system/ko/soph_mipi_rx.ko /mnt/system/ko/soph_mipi_rx.ko");
|
||||
system("sync");
|
||||
system("reboot");
|
||||
} else {
|
||||
system("sync");
|
||||
system("/etc/init.d/S15kvmhwd start");
|
||||
system("/etc/init.d/S95nanokvm restart");
|
||||
}
|
||||
} else {
|
||||
if((RW_Data_0[0] != '/') && (RW_Data_1[0] != '/')){
|
||||
system("cp /kvmapp/system/ko/soph_mipi_rx.ko /mnt/system/ko/soph_mipi_rx.ko");
|
||||
system("sync");
|
||||
system("reboot");
|
||||
} else {
|
||||
system("sync");
|
||||
system("/etc/init.d/S15kvmhwd start");
|
||||
system("/etc/init.d/S95nanokvm restart");
|
||||
}
|
||||
bool current_driver = strcmp(digest, "69be7eeded3777f750480a5dd5a1aa26") == 0 ||
|
||||
(hdmi_ver != 2 && strcmp(digest, "086ed01749188975afaa40fb569374f8") == 0);
|
||||
if(!current_driver){
|
||||
if(!init_command("cp /kvmapp/system/ko/soph_mipi_rx.ko /mnt/system/ko/soph_mipi_rx.ko")) return APP_INIT_FAILED;
|
||||
}
|
||||
|
||||
if(access("/root/old/kvm_new_img", F_OK) == 0){
|
||||
system("rm -r /root/old");
|
||||
}
|
||||
|
||||
system("sync");
|
||||
system("killall NanoKVM-Server");
|
||||
system("rm -r /tmp/server");
|
||||
system("cp -r /kvmapp/server /tmp/");
|
||||
system("/tmp/server/NanoKVM-Server &");
|
||||
if(unlink("/kvmapp/kvm_new_app") != 0) return APP_INIT_FAILED;
|
||||
sync();
|
||||
return current_driver ? APP_INIT_OK : APP_INIT_REBOOT;
|
||||
}
|
||||
|
||||
void build_complete_resolv(void)
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
#include "config.h"
|
||||
|
||||
void new_img_init(void);
|
||||
void new_app_init(void);
|
||||
enum AppInitResult { APP_INIT_OK = 0, APP_INIT_FAILED = 1, APP_INIT_REBOOT = 2 };
|
||||
AppInitResult new_app_init(void);
|
||||
void Production_testing_patch(void);
|
||||
|
||||
#endif // SYSTEM_INIT_H_
|
||||
|
||||
@@ -192,10 +192,15 @@ int main(int argc, char* argv[])
|
||||
new_img_init();
|
||||
}
|
||||
|
||||
// Execute only when it's a new app
|
||||
const bool migrate_only = argc == 2 && strcmp(argv[1], "--migrate") == 0;
|
||||
if(access("/kvmapp/kvm_new_app", F_OK) == 0){
|
||||
new_app_init();
|
||||
AppInitResult result = new_app_init();
|
||||
if(migrate_only || result == APP_INIT_FAILED) return result;
|
||||
// A legacy init script starts us without --migrate and can still be
|
||||
// copying the server. Boot once into the newly installed init script.
|
||||
return system("reboot") == 0 ? 0 : 1;
|
||||
}
|
||||
if(migrate_only) return APP_INIT_OK;
|
||||
|
||||
// only for production testing
|
||||
if (access("/tmp/S49kvmtest", F_OK) == 0){
|
||||
|
||||
@@ -1,61 +1,23 @@
|
||||
#include "kvm_vision.h"
|
||||
#include "maix_basic.hpp"
|
||||
|
||||
using namespace maix;
|
||||
using namespace maix::sys;
|
||||
|
||||
// #define NOT_GET_IMG
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
uint64_t __attribute__((unused)) start_time;
|
||||
// Catch SIGINT signal(e.g. Ctrl + C), and set exit flag to true.
|
||||
signal(SIGINT, [](int sig){ app::set_exit_flag(true);
|
||||
log::info("========================\n");
|
||||
});
|
||||
|
||||
kvmv_hdmi_control(0);
|
||||
kvmv_hdmi_control(1);
|
||||
|
||||
kvmv_init(0);
|
||||
set_h264_gop(30);
|
||||
|
||||
uint16_t get_fream_count = 0;
|
||||
|
||||
while(!app::need_exit()){
|
||||
#ifdef NOT_GET_IMG
|
||||
printf("NOT_GET_IMG ...\n");
|
||||
time::sleep_ms(1000);
|
||||
#else
|
||||
uint8_t* p_kvmv_img_data;
|
||||
uint32_t kvmv_img_data_size;
|
||||
int ret;
|
||||
|
||||
printf("KVM-Vison Get Fream ...\n");
|
||||
|
||||
start_time = time::time_ms();
|
||||
if(get_fream_count < 1){
|
||||
get_fream_count ++;
|
||||
ret = kvmv_read_img(1920, 1080, 1, 3000, &p_kvmv_img_data, &kvmv_img_data_size);
|
||||
} else if(get_fream_count >= 1 && get_fream_count < 2){
|
||||
get_fream_count ++;
|
||||
ret = kvmv_read_img(0, 0, 0, 60, &p_kvmv_img_data, &kvmv_img_data_size);
|
||||
} else {
|
||||
get_fream_count = 0;
|
||||
}
|
||||
|
||||
// printf("kvmv_read_img(): %d \r\n", (int)(time::time_ms() - start_time));
|
||||
|
||||
// ret = kvmv_read_img(1920, 1080, 1, 3000, &p_kvmv_img_data, &kvmv_img_data_size);
|
||||
// ret = kvmv_read_img(1920, 1080, 0, 60, &p_kvmv_img_data, &kvmv_img_data_size);
|
||||
|
||||
printf("kvmv_read_img ret = %d\n", ret);
|
||||
|
||||
// send...
|
||||
// if(ret >= 0){
|
||||
free_kvmv_data(&p_kvmv_img_data);
|
||||
// }
|
||||
#endif
|
||||
}
|
||||
kvmv_deinit();
|
||||
}
|
||||
#include "kvm_vision.h"
|
||||
#include "maix_basic.hpp"
|
||||
|
||||
using namespace maix;
|
||||
|
||||
int main()
|
||||
{
|
||||
signal(SIGINT, [](int) { app::set_exit_flag(true); });
|
||||
kvmv_hdmi_control(1);
|
||||
kvmv_init(0);
|
||||
set_h264_gop(30);
|
||||
|
||||
uint8_t codec = 0;
|
||||
while (!app::need_exit()) {
|
||||
kvmv_frame_t frame = {};
|
||||
int result = kvmv_read_img(1920, 1080, codec, codec ? 3000 : 60, 60, &frame);
|
||||
printf("codec=%u result=%d size=%u pts=%llu\n", codec, result, frame.size,
|
||||
static_cast<unsigned long long>(frame.timestamp));
|
||||
kvmv_release_frame(&frame);
|
||||
codec ^= 1;
|
||||
}
|
||||
kvmv_deinit();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ src
|
||||
├── pages // web pages
|
||||
│ ├── auth // login and password
|
||||
│ ├── desktop // remote desktop
|
||||
│ ├── storage // virtual media and the files inside them
|
||||
│ └── terminal // web terminal
|
||||
├── router.tsx // routers
|
||||
└── types // types
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@xterm/addon-attach": "^0.11.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user