mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-11 02:19:24 -06:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02242c1908 | ||
|
|
509a22f6bd | ||
|
|
5572e1de35 | ||
|
|
d0ff328db8 | ||
|
|
ff45b46c49 | ||
|
|
bd070b2f5c | ||
|
|
5c0bef019f | ||
|
|
283c1390e5 | ||
|
|
7f95fe9bbd | ||
|
|
a9e1ef2459 | ||
|
|
2ba45a2147 | ||
|
|
9e6d280826 | ||
|
|
527caeff6a | ||
|
|
71ab9127dd | ||
|
|
d382f0621d | ||
|
|
9415afb359 | ||
|
|
e5f6dfabaa | ||
|
|
757e0f61c5 | ||
|
|
761b02ed7e | ||
|
|
562ea8277c | ||
|
|
7abec3cbad | ||
|
|
8fc31553f7 | ||
|
|
9e50040713 | ||
|
|
0f8ef20fb5 | ||
|
|
7cda3b2920 |
52
.devcontainer/Dockerfile
Normal file
52
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,52 @@
|
||||
# Dev container image for VS Code / the devcontainer CLI.
|
||||
#
|
||||
# The release builder image (docker/Dockerfile) stays the single source of
|
||||
# truth for the RISC-V cross toolchain, Go and MaixCDK. This layer only adds
|
||||
# the frontend toolchain on top: the builder image deliberately ships without
|
||||
# Node (`make web` runs on the host), but an IDE container should cover web
|
||||
# development as well.
|
||||
#
|
||||
# BASE_IMAGE can be overridden to a locally built builder image, e.g. the
|
||||
# output of `make builder-image` (nanokvm-builder-local-<uid>-<gid>).
|
||||
ARG BASE_IMAGE=ghcr.io/sipeed/nanokvm-builder:latest
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
USER root
|
||||
|
||||
# Node major and pnpm major track .github/workflows/package.yml ("Set up
|
||||
# Node" / "Set up pnpm") and web/package.json "engines" -- keep all of them
|
||||
# in sync when CI bumps either.
|
||||
ARG NODE_MAJOR=22
|
||||
ARG PNPM_VERSION=11
|
||||
# Both can be overridden behind restrictive or mirrored corporate networks
|
||||
# via devcontainer.json, e.g.:
|
||||
# "build": {"args": {
|
||||
# "NODE_DIST_MIRROR": "https://registry.npmmirror.com/-/binary/node",
|
||||
# "NPM_REGISTRY": "https://registry.npmmirror.com"
|
||||
# }}
|
||||
ARG NODE_DIST_MIRROR=https://nodejs.org/dist
|
||||
ARG NPM_REGISTRY=https://registry.npmjs.org
|
||||
|
||||
# Install the latest Node of the pinned major from official dist tarballs;
|
||||
# tracking the latest patch release is intentional (rebuild the container to
|
||||
# pick up updates). The SHA-256 check guards download integrity -- it shares
|
||||
# its origin with the tarball, so it is not an authenticity proof.
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y ca-certificates curl xz-utils; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
case "$(uname -m)" in \
|
||||
x86_64) node_arch=x64 ;; \
|
||||
aarch64) node_arch=arm64 ;; \
|
||||
*) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
curl -fsSL "${NODE_DIST_MIRROR}/latest-v${NODE_MAJOR}.x/SHASUMS256.txt" \
|
||||
-o /tmp/SHASUMS256.txt; \
|
||||
tarball="$(grep -o "node-v[0-9.]*-linux-${node_arch}\.tar\.xz" /tmp/SHASUMS256.txt | head -n1)"; \
|
||||
curl -fsSL "${NODE_DIST_MIRROR}/latest-v${NODE_MAJOR}.x/${tarball}" -o "/tmp/${tarball}"; \
|
||||
cd /tmp && grep " ${tarball}\$" SHASUMS256.txt | sha256sum -c -; \
|
||||
tar -xJf "/tmp/${tarball}" -C /usr/local --strip-components=1 --no-same-owner; \
|
||||
rm -f "/tmp/${tarball}" /tmp/SHASUMS256.txt; \
|
||||
node --version && npm --version
|
||||
RUN npm install --global --registry "${NPM_REGISTRY}" "pnpm@${PNPM_VERSION}" \
|
||||
&& pnpm --version
|
||||
49
.devcontainer/devcontainer.json
Normal file
49
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,49 @@
|
||||
// Dev container for NanoKVM: Go cross-compilation, MaixCDK C support layer
|
||||
// and web frontend in a single container. See "Development" in README.md.
|
||||
{
|
||||
"name": "NanoKVM",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile"
|
||||
},
|
||||
// The Makefile and support/sg2002/build expect the repository at
|
||||
// ~/NanoKVM of the baked-in "build" user -- mount it exactly there.
|
||||
"workspaceMount": "source=${localWorkspaceFolder},target=/home/build/NanoKVM,type=bind,consistency=cached",
|
||||
"workspaceFolder": "/home/build/NanoKVM",
|
||||
"remoteUser": "build",
|
||||
// The published builder image bakes in the CI runner's uid (currently
|
||||
// 1001); on Linux hosts this remaps "build" to the local uid so the bind
|
||||
// mount stays writable. The remap chowns /home/build once, which makes
|
||||
// the first container creation noticeably slower.
|
||||
"updateRemoteUserUID": true,
|
||||
// Equivalent of `. ~/MaixCDK/bin/activate` for every process spawned in
|
||||
// the container, interactive or not (tasks, extensions, exec).
|
||||
"remoteEnv": {
|
||||
"VIRTUAL_ENV": "/home/build/MaixCDK",
|
||||
"PATH": "/home/build/MaixCDK/bin:${containerEnv:PATH}"
|
||||
},
|
||||
"forwardPorts": [3001],
|
||||
"portsAttributes": {
|
||||
"3001": {
|
||||
"label": "web dev server (vite)"
|
||||
}
|
||||
},
|
||||
"postCreateCommand": ".devcontainer/post-create.sh",
|
||||
// This configuration itself mounts nothing from the host (VS Code may
|
||||
// still forward your SSH agent and git credentials by default). To use
|
||||
// git over SSH or reach a NanoKVM device from inside the container,
|
||||
// uncomment the mount below.
|
||||
// "mounts": [
|
||||
// "source=${localEnv:HOME}/.ssh,target=/home/build/.ssh,type=bind,readonly"
|
||||
// ],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"golang.go",
|
||||
"ms-vscode.cpptools",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
37
.devcontainer/post-create.sh
Executable file
37
.devcontainer/post-create.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs once inside the dev container after it is created (see
|
||||
# .devcontainer/devcontainer.json). Keeps the container aligned with the
|
||||
# CLI flow in the Makefile and scripts/build-in-container.sh.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
# The MaixCDK components baked into the image can be older than this
|
||||
# checkout; resync them so C builds never use stale sources (same reason
|
||||
# scripts/build-in-container.sh runs update_lib first).
|
||||
"$repo_root/support/sg2002/build" update_lib
|
||||
|
||||
# Frontend dependencies. postCreate has no TTY, and CI=true lets pnpm make
|
||||
# its non-interactive choices -- e.g. purging a node_modules tree that was
|
||||
# installed from the host OS through the bind mount.
|
||||
(cd "$repo_root/web" && CI=true pnpm install --frozen-lockfile)
|
||||
|
||||
# Start every interactive shell with the MaixCDK virtualenv active,
|
||||
# mirroring `make shell`.
|
||||
activate_line='. "$HOME/MaixCDK/bin/activate"'
|
||||
if ! grep -qxF "$activate_line" "$HOME/.bashrc" 2>/dev/null; then
|
||||
printf '\n# MaixCDK virtualenv (added by .devcontainer/post-create.sh)\n%s\n' \
|
||||
"$activate_line" >>"$HOME/.bashrc"
|
||||
fi
|
||||
|
||||
# The build user's home has no shell skeleton files (useradd ran with
|
||||
# --no-create-home), so login shells would skip ~/.bashrc; give them the
|
||||
# standard Debian chain.
|
||||
if [ ! -f "$HOME/.profile" ]; then
|
||||
cat >"$HOME/.profile" <<'EOF'
|
||||
# ~/.profile (added by .devcontainer/post-create.sh)
|
||||
if [ -n "$BASH_VERSION" ] && [ -f "$HOME/.bashrc" ]; then
|
||||
. "$HOME/.bashrc"
|
||||
fi
|
||||
EOF
|
||||
fi
|
||||
5
.github/workflows/package.yml
vendored
5
.github/workflows/package.yml
vendored
@@ -251,6 +251,11 @@ jobs:
|
||||
echo "sha512_base64=${SHA512_BASE64}"
|
||||
} > build/release/BUILD_INFO.txt
|
||||
|
||||
- name: Verify release assets
|
||||
run: |
|
||||
./scripts/verify-release-assets.sh \
|
||||
build/release "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -147,7 +147,7 @@ jobs:
|
||||
./scripts/verify-release-tag.sh "$TAG" "$SOURCE_SHA" >/dev/null
|
||||
gh release create "$TAG" \
|
||||
--verify-tag \
|
||||
--title "$TAG" \
|
||||
--title "nanokvm@${TAG}" \
|
||||
--notes "$NOTES" \
|
||||
"${STATE_ARGS[@]}" \
|
||||
"$TARBALL" build/release/latest.json build/release/sha256.txt
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -5,6 +5,10 @@ web/node_modules
|
||||
web/dist
|
||||
web/web
|
||||
|
||||
# pnpm keeps its content-addressable store next to the project when the
|
||||
# home directory is on a different filesystem (e.g. inside the dev container)
|
||||
.pnpm-store/
|
||||
|
||||
.DS_Store
|
||||
*.local
|
||||
*.suo
|
||||
@@ -32,3 +36,5 @@ kvmapp/kvm_system/kvm_system
|
||||
|
||||
# Release packages assembled by scripts/package.sh
|
||||
/build/
|
||||
kvmapp/server
|
||||
kvmapp/version
|
||||
|
||||
146
CHANGELOG.md
146
CHANGELOG.md
@@ -1,3 +1,149 @@
|
||||
## 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
|
||||
|
||||
* Added device-level multi-user support with `admin` and `user` roles and a user management page under Settings, separating administrative areas (terminal, scripts, storage, network, updates, MCP, PicoClaw and Tailscale) from the video, keyboard, mouse, paste, wake-on-LAN and power controls every account keeps
|
||||
* Moved sessions to HttpOnly cookies with a per-user token version, so changing a password or disabling an account immediately ends that user's open web and WebSocket sessions
|
||||
* Added support for a custom update server, so the application can be updated from your own mirror, with credentials in the URL, manifest validation and a settings page for configuring it
|
||||
* Added SHA-256 verification of offline update packages before they are installed
|
||||
* Added configurable mouse input regions for absolute mode, with automatic black-border detection, manual selection of the controllable area on screen, and original-resolution presets, so the pointer stays aligned when the target machine outputs a letterboxed or non-16:9 signal
|
||||
* Added touch gestures to relative mouse mode, including tap, drag, long press, double-tap drag and two-finger scroll, so relative mode is now usable from a phone or tablet
|
||||
* Added a `signal` field to `/api/vm/hdmi` reporting whether an HDMI source is actually present, not just whether capture is enabled
|
||||
* Added a default root shell profile showing the user, host and working directory in the prompt over SSH and the Web terminal
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fixed mouse coordinates being offset in Direct H.264 mode when the target machine pads the picture with black bars
|
||||
* Fixed touch input in absolute mode, and stopped taps on the black letterbox area from being sent to the target machine
|
||||
* Fixed the video area not fitting the window, by scaling H.264 and MJPEG playback to contain and letting the page handle touch instead of the browser
|
||||
* Fixed WebRTC H.264 failures leaving a silent black screen; signaling, negotiation, ICE and playback failures now report what went wrong and offer a retry
|
||||
* Fixed a slow WebRTC viewer stalling the shared H.264 source for everyone else, by dropping its buffered frames until the next key frame
|
||||
* Fixed application updates running from a temporary directory that could be too small or shared with another update; online and offline updates are now staged in isolated persistent workspaces with archive, manifest and free-space checks, and the last rollback backup is kept when space runs short
|
||||
* Fixed the update lock being released before the server finished restarting, which allowed a second update to start and be killed mid-install
|
||||
* Fixed Web terminal sessions not loading the root profile, by starting them as login shells in `/root`
|
||||
|
||||
### Performance
|
||||
|
||||
* Routed Direct and WebRTC viewers through a single shared H.264 capture source instead of each reading the encoder separately
|
||||
|
||||
### Localization
|
||||
|
||||
* Synchronized translations of other languages according to English
|
||||
|
||||
### Chores
|
||||
|
||||
* Upgraded the web build tooling to Vite 8 and switched to Vite's native tsconfig path resolution
|
||||
* Added a dev container for full-stack development
|
||||
* Prefixed GitHub Release titles with `nanokvm`
|
||||
* Added release asset verification with per-device package size limits to the packaging workflow
|
||||
|
||||
## 2.5.0 (2026-08-04)
|
||||
|
||||
### Features
|
||||
|
||||
* Added an MCP service that lets trusted MCP clients capture screenshots and control the keyboard and mouse with an API key, including a settings page for enabling it and regenerating the key
|
||||
* Added coordinated device control so MCP, PicoClaw and manual input hand ownership over to each other instead of writing HID input at the same time
|
||||
* Added remote keyboard lock indicators that show the target machine's Num Lock, Caps Lock and Scroll Lock state
|
||||
* Added an optional SHA-256 checksum and cancellation to remote image downloads
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fixed the USB network gadget getting a random host-side MAC on every bind, which made the attached PC register a new network adapter after each reboot; both the device and host MAC are now derived from the chip UID (thanks to [@BeaconCat](https://github.com/BeaconCat))
|
||||
* Fixed OLED sleep durations above 255 seconds being truncated, so the 5 min, 10 min, 30 min and 1 hour options now behave as selected
|
||||
* Fixed the mounted image API returning an error in HID-Only mode
|
||||
* Fixed image downloads and application updates interfering with each other when running at the same time
|
||||
* Fixed Direct H.264 playback falling behind when the decoder queue backed up, by resynchronizing at the next key frame and preferring hardware decoding
|
||||
* Added a warning when Direct and WebRTC H.264 stream modes are used at the same time
|
||||
* Made the video input state shared between processes and read it without shell pipelines, so HDMI status stays consistent between `NanoKVM-Server` and `kvm_system`
|
||||
|
||||
### Performance
|
||||
|
||||
* Reduced latency in 60 Hz mode with decode-driven flow control, GOP-aware bounded queues, and dedicated writer goroutines with write deadlines so a slow client no longer stalls the others
|
||||
* Integrated zero-copy H.264 capture into the video pipeline instead of relying on a preload hook
|
||||
* Stopped HDMI capture when no user is logged in or all viewers are idle, reducing power consumption
|
||||
|
||||
### UI Improvements
|
||||
|
||||
* Tidied the image download dialog layout and truncated long file names in the upload box
|
||||
|
||||
### Localization
|
||||
|
||||
* Synchronized translations of other languages according to English
|
||||
|
||||
### Chores
|
||||
|
||||
* Split release automation into separate package, tag and release workflows, and produced identifiable build artifacts with checksums for pull requests
|
||||
* Allowed overriding the builder image, installed `patchelf` in the build environment, and fixed the exit codes of `support/sg2002/build`
|
||||
* Fixed slow container builds caused by uid/gid mismatch and reused existing container users and groups
|
||||
|
||||
## 2.4.3 (2026-06-09)
|
||||
|
||||
### Features
|
||||
|
||||
12
README.md
12
README.md
@@ -91,6 +91,18 @@ Start with the guide that matches the part of NanoKVM you want to work on:
|
||||
|
||||
> Backend compilation and runtime validation require the target toolchain or a NanoKVM device. See the module-specific guides above for the latest development workflow.
|
||||
|
||||
### Dev container (optional)
|
||||
|
||||
The repository ships a [Dev Container](https://containers.dev) setup (`.devcontainer/`) that layers the frontend toolchain (Node 22, pnpm 11) on top of the release builder image (RISC-V cross toolchain, Go, MaixCDK), so one container covers Go, C support-layer and web development. It is a convenience for IDE users; the `make` targets in the repository root remain the canonical CLI workflow.
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- Hardware-dependent verification still requires a real NanoKVM device; the container covers compiling, linting and mock-mode web development only.
|
||||
|
||||
## 🔩 Hardware Platform (NanoKVM Cube/PCIe)
|
||||
|
||||
NanoKVM is based on Sipeed [LicheeRV Nano](https://wiki.sipeed.com/hardware/zh/lichee/RV_Nano/1_intro.html). You can find specifications, schematics, and dimensional drawings in the [download station](https://dl.sipeed.com/shareURL/LICHEE/LicheeRV_Nano).
|
||||
|
||||
@@ -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,9 +1,82 @@
|
||||
#!/bin/sh
|
||||
# nanokvm Rev3.1
|
||||
|
||||
wait_for_exit() {
|
||||
process="$1"
|
||||
timeout="$2"
|
||||
elapsed=0
|
||||
|
||||
while pidof "$process" >/dev/null 2>&1; do
|
||||
if [ "$elapsed" -ge "$timeout" ]; then
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
stop_process() {
|
||||
process="$1"
|
||||
|
||||
if ! pidof "$process" >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
|
||||
killall -INT "$process" 2>/dev/null || true
|
||||
if wait_for_exit "$process" 20; then
|
||||
return
|
||||
fi
|
||||
|
||||
killall -TERM "$process" 2>/dev/null || true
|
||||
if wait_for_exit "$process" 5; then
|
||||
return
|
||||
fi
|
||||
|
||||
killall -KILL "$process" 2>/dev/null || true
|
||||
wait_for_exit "$process" 2 || true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
start_services() {
|
||||
cp -r /kvmapp/kvm_system /tmp/
|
||||
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 &
|
||||
fi
|
||||
|
||||
if ! pidof NanoKVM-Server >/dev/null 2>&1; then
|
||||
rm -rf /tmp/server
|
||||
cp -r /kvmapp/server /tmp/
|
||||
/tmp/server/NanoKVM-Server &
|
||||
fi
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
echo -n kvm > /boot/hostname.prefix
|
||||
[ -e /root/.profile ] || printf '%s\n' 'export PS1="\u@\h:\w# "' > /root/.profile
|
||||
|
||||
# Copy sensor config if the file exists
|
||||
[ -f /mnt/data/sensor_cfg.ini.LT ] && cp /mnt/data/sensor_cfg.ini.LT /mnt/data/sensor_cfg.ini
|
||||
@@ -45,30 +118,20 @@ case "$1" in
|
||||
fi
|
||||
|
||||
# Start services
|
||||
cp -r /kvmapp/kvm_system /tmp/
|
||||
/tmp/kvm_system/kvm_system &
|
||||
|
||||
cp -r /kvmapp/server /tmp/
|
||||
/tmp/server/NanoKVM-Server &
|
||||
start_services
|
||||
;;
|
||||
|
||||
stop)
|
||||
killall kvm_system
|
||||
killall NanoKVM-Server
|
||||
rm -r /tmp/kvm_system /tmp/server
|
||||
stop_services
|
||||
echo "OK"
|
||||
;;
|
||||
|
||||
restart)
|
||||
killall kvm_system
|
||||
killall NanoKVM-Server
|
||||
rm -r /tmp/kvm_system /tmp/server
|
||||
|
||||
cp -r /kvmapp/kvm_system /tmp/
|
||||
/tmp/kvm_system/kvm_system &
|
||||
|
||||
cp -r /kvmapp/server /tmp/
|
||||
/tmp/server/NanoKVM-Server &
|
||||
if [ -f /tmp/.nanokvm_migrating ]; then
|
||||
exit 0
|
||||
fi
|
||||
stop_services
|
||||
start_services
|
||||
|
||||
sync
|
||||
echo "OK"
|
||||
@@ -78,4 +141,4 @@ case "$1" in
|
||||
echo "Usage: $0 {start|stop|restart}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
esac
|
||||
|
||||
@@ -16,9 +16,9 @@ over the air: `nanokvm_<version>.tar.gz` plus its `latest.json` manifest.
|
||||
The format is not arbitrary — it is fixed by the on-device updater in
|
||||
`server/service/application/`:
|
||||
|
||||
- **One root directory.** `install.go` untars the package and moves the single
|
||||
top-level directory over `/kvmapp`, so the tarball must contain exactly
|
||||
`nanokvm_<version>/`.
|
||||
- **One safe root directory.** The updater scans before extracting and accepts
|
||||
only directories and regular files under `nanokvm_<version>/`; links,
|
||||
special files, duplicate paths, and path traversal are rejected.
|
||||
- **`name` is the file name.** `version.go` builds the download URL as
|
||||
`<base>/<name>`, where `<base>` is `https://cdn.sipeed.com/nanokvm` (or
|
||||
`.../preview` when `/etc/kvm/preview_updates` exists).
|
||||
@@ -27,9 +27,13 @@ The format is not arbitrary — it is fixed by the on-device updater in
|
||||
- **`/kvmapp/version`** is what the device reports as its installed version, so
|
||||
it must match the `version` field.
|
||||
|
||||
`size` is parsed into `Latest.Size` but never read anywhere, so nothing on the
|
||||
device depends on its units. Published manifests have carried a kilobyte-ish
|
||||
value; `package.sh` writes the exact byte count instead.
|
||||
New releases use manifest v2. `size` remains the exact compressed byte count
|
||||
for older clients, while v2 clients use `size_bytes` and
|
||||
`unpacked_size_bytes` for storage preflight checks. Historical v1 `size`
|
||||
values have inconsistent units, so v2 clients only require v1 `size` to be
|
||||
non-zero and verify the downloaded SHA-512 instead. Publish v2 metadata before
|
||||
releasing clients that consume it; clients retain v1 compatibility for custom
|
||||
update servers.
|
||||
|
||||
## Building a release
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
# download URL as "<base>/<name>", so "name" must be the tarball file name.
|
||||
# - server/service/application/update.go verifies the download against
|
||||
# "sha512", which is the *base64* encoding of the raw SHA-512 digest.
|
||||
# - server/service/application/install.go untars the package and moves the
|
||||
# single top-level directory over /kvmapp, so the tarball must contain
|
||||
# exactly one root directory: nanokvm_<version>/.
|
||||
# - server/service/application/archive.go validates and extracts the package
|
||||
# before install.go moves its single top-level directory over /kvmapp, so
|
||||
# the tarball must contain exactly one root directory: nanokvm_<version>/.
|
||||
#
|
||||
# Build artifacts are expected to be in place already (see
|
||||
# scripts/build-in-container.sh and the "web" target in the Makefile):
|
||||
@@ -157,7 +157,7 @@ printf '0\n' > "$STAGE/kvm/now_fps"
|
||||
printf '60\n' > "$STAGE/kvm/qlty"
|
||||
printf '1920\n' > "$STAGE/kvm/width"
|
||||
printf '1080\n' > "$STAGE/kvm/height"
|
||||
printf '1\n' > "$STAGE/kvm/state"
|
||||
printf '0\n' > "$STAGE/kvm/state"
|
||||
printf 'mjpeg\n' > "$STAGE/kvm/type"
|
||||
printf '0' > "$STAGE/kvm/res"
|
||||
|
||||
@@ -199,14 +199,29 @@ fi
|
||||
# --- manifest ----------------------------------------------------------------
|
||||
# update.go compares base64(raw sha512), not the hex digest.
|
||||
SHA512="$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)"
|
||||
SIZE="$(wc -c < "$TARBALL" | tr -d ' ')"
|
||||
SIZE_BYTES="$(wc -c < "$TARBALL" | tr -d ' ')"
|
||||
UNPACKED_SIZE_BYTES="$(python3 - "$TARBALL" <<'PY'
|
||||
import sys
|
||||
import tarfile
|
||||
|
||||
total = 0
|
||||
with tarfile.open(sys.argv[1], "r:gz") as archive:
|
||||
for member in archive:
|
||||
if member.isfile():
|
||||
total += member.size
|
||||
print(total)
|
||||
PY
|
||||
)"
|
||||
|
||||
cat > "$MANIFEST" <<EOF
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"version": "$VERSION",
|
||||
"name": "nanokvm_$VERSION.tar.gz",
|
||||
"sha512": "$SHA512",
|
||||
"size": $SIZE
|
||||
"size": $SIZE_BYTES,
|
||||
"size_bytes": $SIZE_BYTES,
|
||||
"unpacked_size_bytes": $UNPACKED_SIZE_BYTES
|
||||
}
|
||||
EOF
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ for path in "$TARBALL" "$MANIFEST" "$CHECKSUM"; do
|
||||
done
|
||||
|
||||
PACKAGE_ROOT="nanokvm_${VERSION}"
|
||||
MAX_PACKAGE_SIZE=$((1 << 30))
|
||||
MAX_UNPACKED_SIZE=$((2 << 30))
|
||||
MAX_ARCHIVE_ENTRIES=100000
|
||||
ENTRY_LIST=$(mktemp)
|
||||
VERBOSE_LIST=$(mktemp)
|
||||
trap 'rm -f "$ENTRY_LIST" "$VERBOSE_LIST"' EXIT
|
||||
@@ -44,6 +47,10 @@ fi
|
||||
ENTRY_COUNT=0
|
||||
while IFS= read -r entry; do
|
||||
ENTRY_COUNT=$((ENTRY_COUNT + 1))
|
||||
if [ "$ENTRY_COUNT" -gt "$MAX_ARCHIVE_ENTRIES" ]; then
|
||||
echo "[ERROR] archive contains more than $MAX_ARCHIVE_ENTRIES entries" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$entry" in
|
||||
"$PACKAGE_ROOT"|"$PACKAGE_ROOT"/*) ;;
|
||||
*)
|
||||
@@ -92,6 +99,9 @@ fi
|
||||
MANIFEST_VERSION=$(jq -er '.version | select(type == "string" and length > 0)' "$MANIFEST")
|
||||
MANIFEST_NAME=$(jq -er '.name | select(type == "string" and length > 0)' "$MANIFEST")
|
||||
MANIFEST_SIZE=$(jq -er '.size | select(type == "number" and . >= 0 and floor == .)' "$MANIFEST")
|
||||
MANIFEST_FORMAT=$(jq -er '.manifest_version | select(type == "number" and . == 2)' "$MANIFEST")
|
||||
MANIFEST_SIZE_BYTES=$(jq -er '.size_bytes | select(type == "number" and . > 0 and floor == .)' "$MANIFEST")
|
||||
MANIFEST_UNPACKED_SIZE_BYTES=$(jq -er '.unpacked_size_bytes | select(type == "number" and . > 0 and floor == .)' "$MANIFEST")
|
||||
MANIFEST_SHA512=$(jq -er '.sha512 | select(type == "string" and length > 0)' "$MANIFEST")
|
||||
|
||||
if [ "$MANIFEST_VERSION" != "$VERSION" ]; then
|
||||
@@ -104,10 +114,39 @@ if [ "$MANIFEST_NAME" != "$TARBALL_NAME" ]; then
|
||||
fi
|
||||
|
||||
ACTUAL_SIZE=$(wc -c < "$TARBALL" | tr -d ' ')
|
||||
if [ "$MANIFEST_SIZE" != "$ACTUAL_SIZE" ]; then
|
||||
if [ "$MANIFEST_FORMAT" != "2" ]; then
|
||||
echo "[ERROR] latest.json manifest_version must be 2" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$MANIFEST_SIZE" != "$ACTUAL_SIZE" ] || [ "$MANIFEST_SIZE_BYTES" != "$ACTUAL_SIZE" ]; then
|
||||
echo "[ERROR] latest.json size '$MANIFEST_SIZE' does not match '$ACTUAL_SIZE'" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$ACTUAL_SIZE" -gt "$MAX_PACKAGE_SIZE" ]; then
|
||||
echo "[ERROR] release tarball exceeds device limit of $MAX_PACKAGE_SIZE bytes" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTUAL_UNPACKED_SIZE=$(python3 - "$TARBALL" <<'PY'
|
||||
import sys
|
||||
import tarfile
|
||||
|
||||
total = 0
|
||||
with tarfile.open(sys.argv[1], "r:gz") as archive:
|
||||
for member in archive:
|
||||
if member.isfile():
|
||||
total += member.size
|
||||
print(total)
|
||||
PY
|
||||
)
|
||||
if [ "$MANIFEST_UNPACKED_SIZE_BYTES" != "$ACTUAL_UNPACKED_SIZE" ]; then
|
||||
echo "[ERROR] latest.json unpacked_size_bytes '$MANIFEST_UNPACKED_SIZE_BYTES' does not match '$ACTUAL_UNPACKED_SIZE'" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$ACTUAL_UNPACKED_SIZE" -gt "$MAX_UNPACKED_SIZE" ]; then
|
||||
echo "[ERROR] unpacked package exceeds device limit of $MAX_UNPACKED_SIZE bytes" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTUAL_SHA512=$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)
|
||||
if [ "$MANIFEST_SHA512" != "$ACTUAL_SHA512" ]; then
|
||||
|
||||
105
server/README.md
105
server/README.md
@@ -48,7 +48,7 @@ authentication: enable # Whether to enable identity verification fo
|
||||
jwt:
|
||||
secretKey: "" # The secret key used to sign and verify JWT Tokens. If left empty, a random key will be generated automatically on startup
|
||||
refreshTokenDuration: 2678400 # The token refresh duration threshold in seconds before forcing a re-login. Default is `2678400` (~31 days)
|
||||
revokeTokensOnLogout: true # Whether to invalidate all existing tokens upon logout by rotating the SecretKey. Default is `true`
|
||||
revokeTokensOnLogout: true # Whether logout invalidates all sessions belonging to that user. Other users are never logged out. Setting this to false only clears the browser cookie and is not recommended. Default is `true`
|
||||
security:
|
||||
loginLockoutDuration: 0, # The duration (in seconds) to ban an IP from attempting to log in again after reaching the failure limit. If set to `0` or left empty, brute-force protection is disabled. Default is `0`
|
||||
loginMaxFailures: 5, # The maximum number of continuous failed login attempts allowed per IP before triggering protection. Default is `5`
|
||||
@@ -62,9 +62,110 @@ turn:
|
||||
turnCred: example_cred # The credential/password required for authorization to the TURN server
|
||||
```
|
||||
|
||||
## Web Users
|
||||
|
||||
NanoKVM uses two device-wide roles:
|
||||
|
||||
- `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
|
||||
the multi-user format atomically with mode `0600`. Keeping the same path preserves the physical
|
||||
BOOT-button password reset behavior.
|
||||
Users must confirm their current password when changing it themselves; administrators can reset
|
||||
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: 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
|
||||
|
||||
Note: Use Linux operating system (x86-64) with Go 1.25 or newer. This build process is not compatible with ARM, Windows or macOS.
|
||||
Note: The manual steps below require a Linux x86-64 host with Go 1.25 or newer; they are not compatible with ARM, Windows or macOS. With Docker you can skip them entirely and use the containerized flow instead — the root [Makefile](../Makefile) (`make shell`) or the dev container (see "Development" in the root [README](../README.md)) — which works on any host OS; run `server/build.sh` inside the container for a release-equivalent build.
|
||||
|
||||
1. Install the Toolchain
|
||||
1. Download the toolchain from the following link: [Download Link](https://sophon-file.sophon.cn/sophon-prod-s3/drive/23/03/07/16/host-tools.tar.gz).
|
||||
|
||||
@@ -46,7 +46,7 @@ authentication: enable # 是否开启 HTTP 接口与网页的身份
|
||||
jwt:
|
||||
secretKey: "" # 用于签发和验证 JWT Token 的密钥。如果不填,服务启动时将自动随机生成
|
||||
refreshTokenDuration: 2678400 # 登录超时的刷新周期(单位:秒)。默认为 `2678400`(约31天)
|
||||
revokeTokensOnLogout: true # 退出登录时是否废除所有现存的 Token。启用此项可以在注销时轮换 SecretKey,强迫所有终端重新登录。默认为 `true`
|
||||
revokeTokensOnLogout: true # 退出登录时是否废除该用户的全部会话;不会影响其他用户。设为 false 时仅清除浏览器 Cookie,不推荐使用。默认为 `true`
|
||||
security:
|
||||
loginLockoutDuration: 0, # 达到失败上限后,禁止该 IP 再次尝试登录的持续时间(单位:秒)。如果设为 `0` 或不填,则代表不开启防暴力破解功能。默认为 `0`
|
||||
loginMaxFailures: 5, # 允许触发保护前,单个 IP 连续登录失败的最大次数。默认为 `5`
|
||||
@@ -60,6 +60,80 @@ turn:
|
||||
turnCred: example_cred # TURN 服务器授权连接时使用的凭据/密码
|
||||
```
|
||||
|
||||
## Web 多用户
|
||||
|
||||
NanoKVM 使用两种设备级角色:
|
||||
|
||||
- `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 与实时连接,且不会影响其他用户。
|
||||
修改角色或权限不会失效登录:账户会收到刷新通知,只会断开缓存了相关状态的长期连接
|
||||
(控制台、视频流、终端、AI),这些客户端随后会以新权限重连。失去 `console.view` 的账户会被
|
||||
转到提示页,而不是一个无法打开的控制台。多个用户可同时观看和协作控制 KVM;输入沿用现有 HID
|
||||
协调器,因此同时输入可能交错。没有 `console.control` 的用户仍可观看画面并使用其其他权限对应的菜单,
|
||||
但控制台会丢弃其键鼠报文,Web 界面会将会话标记为只读。
|
||||
视频模式、画质、分辨率、MJPEG 帧检测以及复位 HDMI 采集只要求 `console.view`,仍属于共享 KVM 操作;
|
||||
多人同时调整时,以最后一次设备级修改为准。启用/关闭 HDMI 采集及其空闲超时则属于设备设置。
|
||||
`console.control` 仅限输入:键盘、鼠标、粘贴,以及将指针位移映射到目标机画面的输入区域。
|
||||
|
||||
账户文件保存的是显式权限集合:细粒度权限出现之前写入的记录,在读取时会物化为当时 user 角色的
|
||||
能力,拆分过的媒体权限也会被翻译成新名称,因此存储中不会留下“缺失即回退到另一套行为”的状态。
|
||||
本版本不认识的权限会被原样保留并忽略,因此回滚版本不会把设备锁在门外。
|
||||
新建普通账户时若不指定权限集合,含义同上:只认识角色的脚本仍会得到 user 角色过去创建出的账户;
|
||||
账户编辑器新建普通账户时只预置 `console.view`,而显式传入的空集合就是真的空。
|
||||
|
||||
## 编译部署
|
||||
|
||||
**注意:请使用 Linux 操作系统(x86-64)和 Go 1.25 或更高版本。该工具链无法在 ARM、Windows 或 macOS 下使用。**
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
719
server/authn/store.go
Normal file
719
server/authn/store.go
Normal file
@@ -0,0 +1,719 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
"NanoKVM-Server/utils"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
AccountFile = "/etc/kvm/pwd"
|
||||
currentFileVersion = 1
|
||||
defaultUsername = "admin"
|
||||
defaultPassword = "admin"
|
||||
)
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleAdmin Role = "admin"
|
||||
RoleUser Role = "user"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserExists = errors.New("username already exists")
|
||||
ErrLastAdmin = errors.New("at least one enabled admin is required")
|
||||
ErrSelfModification = errors.New("administrators cannot disable or demote themselves")
|
||||
ErrSelfDelete = errors.New("administrators cannot delete themselves")
|
||||
ErrSystemAccount = errors.New("the device owner account must remain an enabled administrator")
|
||||
|
||||
usernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$`)
|
||||
)
|
||||
|
||||
type User struct {
|
||||
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"`
|
||||
Permissions []Permission `json:"permissions"`
|
||||
SystemAccount bool `json:"systemAccount,omitempty"`
|
||||
}
|
||||
|
||||
type UserPatch struct {
|
||||
Role *Role
|
||||
Enabled *bool
|
||||
Permissions *[]Permission
|
||||
}
|
||||
|
||||
type database struct {
|
||||
Version int `json:"version"`
|
||||
Users []User `json:"users"`
|
||||
LegacyUsername string `json:"username,omitempty"`
|
||||
LegacyPassword string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
type legacyAccount struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
var DefaultStore = NewStore(AccountFile)
|
||||
|
||||
func NewStore(path string) *Store {
|
||||
return &Store{path: path}
|
||||
}
|
||||
|
||||
func IsValidRole(role Role) bool {
|
||||
return role == RoleAdmin || role == RoleUser
|
||||
}
|
||||
|
||||
func ValidateUsername(username string) error {
|
||||
if !usernamePattern.MatchString(username) {
|
||||
return errors.New("username must be 1-32 characters and contain only letters, numbers, '.', '_' or '-'")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidatePassword(password string) error {
|
||||
length := len([]byte(password))
|
||||
if length < 8 || length > 72 {
|
||||
return errors.New("password must be between 8 and 72 bytes")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) List() ([]UserInfo, error) {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
db, err := s.loadLocked(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
users := make([]UserInfo, 0, len(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,
|
||||
})
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(username string) (*User, error) {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
db, err := s.loadLocked(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return findUser(db.Users, username)
|
||||
}
|
||||
|
||||
func (s *Store) Authenticate(username, password string) (*User, bool, error) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
db, err := s.loadLocked(true)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
user, err := findUser(db.Users, username)
|
||||
if err != nil || !user.Enabled {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) == nil {
|
||||
return user, true, nil
|
||||
}
|
||||
|
||||
// Older releases stored a reversibly encrypted password. Upgrade it after
|
||||
// the first successful login so the compatibility path is not permanent.
|
||||
legacyPassword, decodeErr := utils.DecodeDecrypt(user.PasswordHash)
|
||||
if decodeErr != nil || legacyPassword != password {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
hash, hashErr := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if hashErr != nil {
|
||||
return nil, false, hashErr
|
||||
}
|
||||
for index := range db.Users {
|
||||
if db.Users[index].Username == username {
|
||||
db.Users[index].PasswordHash = string(hash)
|
||||
if db.Users[index].TokenVersion == 0 {
|
||||
db.Users[index].TokenVersion = 1
|
||||
}
|
||||
user = cloneUser(&db.Users[index])
|
||||
break
|
||||
}
|
||||
}
|
||||
if err = s.saveLocked(db); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return user, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) ValidateToken(username string, tokenVersion uint64) (*User, error) {
|
||||
user, err := s.Get(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !user.Enabled || user.TokenVersion != tokenVersion {
|
||||
return nil, errors.New("session revoked")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Store) Create(username, password string, role Role, permissions ...Permission) error {
|
||||
if err := ValidateUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidatePassword(password); err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
tokenVersion, err := newTokenVersion()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
db, err := s.loadLocked(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = findUser(db.Users, username); err == nil {
|
||||
return ErrUserExists
|
||||
}
|
||||
db.Users = append(db.Users, User{
|
||||
Username: username,
|
||||
PasswordHash: string(hash),
|
||||
Role: role,
|
||||
Enabled: true,
|
||||
TokenVersion: tokenVersion,
|
||||
Permissions: NormalizeStoredPermissions(permissions),
|
||||
})
|
||||
return s.saveLocked(db)
|
||||
}
|
||||
|
||||
// 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, nil, err
|
||||
}
|
||||
index := userIndex(db.Users, username)
|
||||
if index < 0 {
|
||||
return nil, nil, ErrUserNotFound
|
||||
}
|
||||
previous = cloneUser(&db.Users[index])
|
||||
user := db.Users[index]
|
||||
|
||||
if patch.Role != nil && !IsValidRole(*patch.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, nil, ErrSelfModification
|
||||
}
|
||||
if user.SystemAccount &&
|
||||
((patch.Role != nil && *patch.Role != RoleAdmin) || (patch.Enabled != nil && !*patch.Enabled)) {
|
||||
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
|
||||
}
|
||||
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 previous, cloneUser(&user), nil
|
||||
}
|
||||
|
||||
db.Users[index] = user
|
||||
if enabledAdminCount(db.Users) == 0 {
|
||||
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++
|
||||
}
|
||||
if err = s.saveLocked(db); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return previous, cloneUser(&db.Users[index]), nil
|
||||
}
|
||||
|
||||
func (s *Store) Delete(actor, username string) error {
|
||||
if actor == username {
|
||||
return ErrSelfDelete
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
db, err := s.loadLocked(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
index := userIndex(db.Users, username)
|
||||
if index < 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
if db.Users[index].SystemAccount {
|
||||
return ErrSystemAccount
|
||||
}
|
||||
users := append([]User(nil), db.Users[:index]...)
|
||||
users = append(users, db.Users[index+1:]...)
|
||||
if enabledAdminCount(users) == 0 {
|
||||
return ErrLastAdmin
|
||||
}
|
||||
db.Users = users
|
||||
return s.saveLocked(db)
|
||||
}
|
||||
|
||||
func (s *Store) SetPassword(username, password string) (*User, error) {
|
||||
return s.SetPasswordAndRun(username, password, nil)
|
||||
}
|
||||
|
||||
// SetPasswordAndRun commits the web password before running the optional
|
||||
// system-side update. If that update fails, the account record is rolled back
|
||||
// while the store lock is still held.
|
||||
func (s *Store) SetPasswordAndRun(username, password string, afterCommit func() error) (*User, error) {
|
||||
if err := ValidatePassword(password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
db, err := s.loadLocked(true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index := userIndex(db.Users, username)
|
||||
if index < 0 {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
previous := *db
|
||||
previous.Users = append([]User(nil), db.Users...)
|
||||
db.Users[index].PasswordHash = string(hash)
|
||||
db.Users[index].MustChangePassword = false
|
||||
db.Users[index].TokenVersion++
|
||||
if err = s.saveLocked(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if afterCommit != nil {
|
||||
if err = afterCommit(); err != nil {
|
||||
if rollbackErr := s.saveLocked(&previous); rollbackErr != nil {
|
||||
return nil, fmt.Errorf("system password update failed: %v; account rollback failed: %w", err, rollbackErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return cloneUser(&db.Users[index]), nil
|
||||
}
|
||||
|
||||
func (s *Store) Revoke(username string) (*User, error) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
db, err := s.loadLocked(true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index := userIndex(db.Users, username)
|
||||
if index < 0 {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
db.Users[index].TokenVersion++
|
||||
if err = s.saveLocked(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cloneUser(&db.Users[index]), nil
|
||||
}
|
||||
|
||||
func (s *Store) loadLocked(migrate bool) (*database, error) {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
db, defaultErr := defaultDatabase()
|
||||
if defaultErr != nil {
|
||||
return nil, defaultErr
|
||||
}
|
||||
materializePermissions(db)
|
||||
if migrate {
|
||||
if saveErr := s.saveLocked(db); saveErr != nil {
|
||||
return nil, saveErr
|
||||
}
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return &db, nil
|
||||
}
|
||||
|
||||
var legacy legacyAccount
|
||||
if err = json.Unmarshal(data, &legacy); err != nil || legacy.Username == "" || legacy.Password == "" {
|
||||
return nil, errors.New("invalid account file")
|
||||
}
|
||||
if err = ValidateUsername(legacy.Username); err != nil {
|
||||
return nil, fmt.Errorf("invalid legacy username: %w", err)
|
||||
}
|
||||
tokenVersion, err := newTokenVersion()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db = database{
|
||||
Version: currentFileVersion,
|
||||
Users: []User{{
|
||||
Username: legacy.Username,
|
||||
PasswordHash: legacy.Password,
|
||||
Role: RoleAdmin,
|
||||
Enabled: true,
|
||||
TokenVersion: tokenVersion,
|
||||
MustChangePassword: passwordMatches(legacy.Password, defaultPassword),
|
||||
SystemAccount: true,
|
||||
}},
|
||||
}
|
||||
materializePermissions(&db)
|
||||
if migrate {
|
||||
if err = s.saveLocked(&db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &db, nil
|
||||
}
|
||||
|
||||
func (s *Store) saveLocked(db *database) error {
|
||||
if err := validateDatabase(db); err != nil {
|
||||
return err
|
||||
}
|
||||
syncLegacyAccount(db)
|
||||
data, err := json.MarshalIndent(db, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
directory := filepath.Dir(s.path)
|
||||
if err = os.MkdirAll(directory, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
temporary, err := os.CreateTemp(directory, ".pwd-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer func() { _ = os.Remove(temporaryPath) }()
|
||||
|
||||
if err = temporary.Chmod(0o600); err == nil {
|
||||
_, err = temporary.Write(data)
|
||||
}
|
||||
if err == nil {
|
||||
err = temporary.Sync()
|
||||
}
|
||||
closeErr := temporary.Close()
|
||||
if err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Rename(temporaryPath, s.path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir, err := os.Open(directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = dir.Close() }()
|
||||
return dir.Sync()
|
||||
}
|
||||
|
||||
func defaultDatabase() (*database, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(defaultPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenVersion, err := newTokenVersion()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &database{
|
||||
Version: currentFileVersion,
|
||||
Users: []User{{
|
||||
Username: defaultUsername,
|
||||
PasswordHash: string(hash),
|
||||
Role: RoleAdmin,
|
||||
Enabled: true,
|
||||
TokenVersion: tokenVersion,
|
||||
MustChangePassword: true,
|
||||
SystemAccount: true,
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newTokenVersion() (uint64, error) {
|
||||
var data [8]byte
|
||||
for {
|
||||
if _, err := rand.Read(data[:]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
version := binary.LittleEndian.Uint64(data[:])
|
||||
if version != 0 && version != ^uint64(0) {
|
||||
return version, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateDatabase(db *database) error {
|
||||
if db.Version != currentFileVersion {
|
||||
return fmt.Errorf("unsupported account file version: %d", db.Version)
|
||||
}
|
||||
if len(db.Users) == 0 {
|
||||
return errors.New("account file contains no users")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(db.Users))
|
||||
systemAccounts := 0
|
||||
for index := range db.Users {
|
||||
user := &db.Users[index]
|
||||
if err := ValidateUsername(user.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
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")
|
||||
}
|
||||
seen[user.Username] = struct{}{}
|
||||
if user.TokenVersion == 0 {
|
||||
user.TokenVersion = 1
|
||||
}
|
||||
if user.SystemAccount {
|
||||
systemAccounts++
|
||||
if user.Role != RoleAdmin || !user.Enabled || systemAccounts > 1 {
|
||||
return ErrSystemAccount
|
||||
}
|
||||
}
|
||||
}
|
||||
if enabledAdminCount(db.Users) == 0 {
|
||||
return ErrLastAdmin
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findUser(users []User, username string) (*User, error) {
|
||||
index := userIndex(users, username)
|
||||
if index < 0 {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return cloneUser(&users[index]), nil
|
||||
}
|
||||
|
||||
func userIndex(users []User, username string) int {
|
||||
for index := range users {
|
||||
if users[index].Username == username {
|
||||
return index
|
||||
}
|
||||
}
|
||||
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 {
|
||||
if user.Role == RoleAdmin && user.Enabled {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func cloneUser(user *User) *User {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *user
|
||||
return ©
|
||||
}
|
||||
|
||||
func passwordMatches(hash, password string) bool {
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil {
|
||||
return true
|
||||
}
|
||||
legacyPassword, err := utils.DecodeDecrypt(hash)
|
||||
return err == nil && legacyPassword == password
|
||||
}
|
||||
|
||||
// Keep a legacy top-level account mirror so downgrading to a single-user
|
||||
// server still authenticates the original device owner instead of failing
|
||||
// open or forcing an immediate physical reset.
|
||||
func syncLegacyAccount(db *database) {
|
||||
db.LegacyUsername = ""
|
||||
db.LegacyPassword = ""
|
||||
var fallback *User
|
||||
for _, user := range db.Users {
|
||||
if user.SystemAccount {
|
||||
db.LegacyUsername = user.Username
|
||||
db.LegacyPassword = user.PasswordHash
|
||||
return
|
||||
}
|
||||
if fallback == nil && user.Role == RoleAdmin && user.Enabled {
|
||||
copy := user
|
||||
fallback = ©
|
||||
}
|
||||
}
|
||||
if fallback != nil {
|
||||
db.LegacyUsername = fallback.Username
|
||||
db.LegacyPassword = fallback.PasswordHash
|
||||
}
|
||||
}
|
||||
212
server/authn/store_test.go
Normal file
212
server/authn/store_test.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestLegacyAccountMigratesInPlace(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pwd")
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("legacy-password"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy, _ := json.Marshal(legacyAccount{Username: "owner", Password: string(hash)})
|
||||
if err = os.WriteFile(path, legacy, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := NewStore(path)
|
||||
user, ok, err := store.Authenticate("owner", "legacy-password")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("authenticate migrated account: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if user.Role != RoleAdmin || !user.SystemAccount || user.TokenVersion == 0 {
|
||||
t.Fatalf("unexpected migrated user: %+v", user)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var db database
|
||||
if err = json.Unmarshal(data, &db); err != nil {
|
||||
t.Fatalf("account file was not migrated: %v", err)
|
||||
}
|
||||
if db.Version != currentFileVersion || len(db.Users) != 1 {
|
||||
t.Fatalf("unexpected database: %+v", db)
|
||||
}
|
||||
var downgrade legacyAccount
|
||||
if err = json.Unmarshal(data, &downgrade); err != nil || downgrade.Username != "owner" || downgrade.Password == "" {
|
||||
t.Fatalf("legacy downgrade mirror is invalid: %+v, %v", downgrade, err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("account mode = %o, want 600", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorruptAccountFileFailsClosed(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pwd")
|
||||
if err := os.WriteFile(path, []byte("not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, ok, err := NewStore(path).Authenticate("admin", "admin")
|
||||
if err == nil || ok {
|
||||
t.Fatalf("corrupt file must fail closed: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserLifecycleAndTokenRevocation(t *testing.T) {
|
||||
store := NewStore(filepath.Join(t.TempDir(), "pwd"))
|
||||
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
|
||||
t.Fatalf("default login: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if err := store.Create("alice", "correct-horse", RoleUser); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, err := store.Authenticate("alice", "wrong-password"); err != nil || ok {
|
||||
t.Fatalf("wrong password login: ok=%v err=%v", ok, err)
|
||||
}
|
||||
alice, err := store.Get("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = store.ValidateToken("alice", alice.TokenVersion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = store.SetPassword("alice", "new-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = store.ValidateToken("alice", alice.TokenVersion); err == nil {
|
||||
t.Fatal("old token stayed valid after password change")
|
||||
}
|
||||
if _, ok, err := store.Authenticate("alice", "new-password"); err != nil || !ok {
|
||||
t.Fatalf("new password login: ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
disabled := false
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.Delete("admin", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = store.Get("alice"); !errors.Is(err, ErrUserNotFound) {
|
||||
t.Fatalf("deleted user lookup error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastAdminAndSelfProtection(t *testing.T) {
|
||||
store := NewStore(filepath.Join(t.TempDir(), "pwd"))
|
||||
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
|
||||
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) {
|
||||
t.Fatalf("self demotion error = %v", err)
|
||||
}
|
||||
if err := store.Delete("admin", "admin"); !errors.Is(err, ErrSelfDelete) {
|
||||
t.Fatalf("self delete error = %v", err)
|
||||
}
|
||||
if err := store.Create("second", "second-password", RoleAdmin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled := false
|
||||
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) {
|
||||
t.Fatalf("demote device owner error = %v", err)
|
||||
}
|
||||
if err := store.Delete("second", "admin"); !errors.Is(err, ErrSystemAccount) {
|
||||
t.Fatalf("delete device owner error = %v", err)
|
||||
}
|
||||
if err := store.Delete("admin", "second"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovedAccountFileAndRecreatedUserInvalidateOldVersions(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pwd")
|
||||
store := NewStore(path)
|
||||
admin, ok, err := store.Authenticate("admin", "admin")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("default login: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if err = os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = store.ValidateToken("admin", admin.TokenVersion); err == nil {
|
||||
t.Fatal("old admin token survived account-file reset")
|
||||
}
|
||||
|
||||
if _, ok, err = store.Authenticate("admin", "admin"); err != nil || !ok {
|
||||
t.Fatalf("login after reset: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if err = store.Create("alice", "alice-password", RoleUser); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alice, err := store.Get("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.Delete("admin", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.Create("alice", "alice-password", RoleUser); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = store.ValidateToken("alice", alice.TokenVersion); err == nil {
|
||||
t.Fatal("old token survived deletion and username reuse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentCreatesDoNotLoseUsers(t *testing.T) {
|
||||
store := NewStore(filepath.Join(t.TempDir(), "pwd"))
|
||||
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
|
||||
t.Fatalf("default login: ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
const count = 5
|
||||
var workers sync.WaitGroup
|
||||
errorsCh := make(chan error, count)
|
||||
for index := 0; index < count; index++ {
|
||||
workers.Add(1)
|
||||
go func(index int) {
|
||||
defer workers.Done()
|
||||
errorsCh <- store.Create(fmt.Sprintf("user%d", index), "valid-password", RoleUser)
|
||||
}(index)
|
||||
}
|
||||
workers.Wait()
|
||||
close(errorsCh)
|
||||
for err := range errorsCh {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
users, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(users) != count+1 {
|
||||
t.Fatalf("got %d users, want %d", len(users), count+1)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,10 @@ var (
|
||||
kvmVisionOnce sync.Once
|
||||
)
|
||||
|
||||
type KvmVision struct{}
|
||||
type KvmVision struct {
|
||||
mutex sync.RWMutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
func GetKvmVision() *KvmVision {
|
||||
kvmVisionOnce.Do(func() {
|
||||
@@ -33,6 +36,12 @@ func GetKvmVision() *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
|
||||
@@ -57,6 +66,12 @@ func (k *KvmVision) ReadMjpeg(width uint16, height uint16, quality uint16) (data
|
||||
}
|
||||
|
||||
func (k *KvmVision) ReadH264(width uint16, height uint16, bitRate 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
|
||||
@@ -81,6 +96,12 @@ func (k *KvmVision) ReadH264(width uint16, height uint16, bitRate uint16) (data
|
||||
}
|
||||
|
||||
func (k *KvmVision) SetHDMI(enable bool) int {
|
||||
k.mutex.RLock()
|
||||
defer k.mutex.RUnlock()
|
||||
if k.closed {
|
||||
return -1
|
||||
}
|
||||
|
||||
hdmiEnable := C.uint8_t(0)
|
||||
if enable {
|
||||
hdmiEnable = C.uint8_t(1)
|
||||
@@ -95,17 +116,46 @@ func (k *KvmVision) SetHDMI(enable bool) int {
|
||||
return result
|
||||
}
|
||||
|
||||
func (k *KvmVision) HasHDMISignal() bool {
|
||||
k.mutex.RLock()
|
||||
defer k.mutex.RUnlock()
|
||||
if k.closed {
|
||||
return false
|
||||
}
|
||||
|
||||
return C.kvmv_hdmi_signal_active() != 0
|
||||
}
|
||||
|
||||
func (k *KvmVision) SetGop(gop uint8) {
|
||||
k.mutex.RLock()
|
||||
defer k.mutex.RUnlock()
|
||||
if k.closed {
|
||||
return
|
||||
}
|
||||
|
||||
_gop := C.uint8_t(gop)
|
||||
C.set_h264_gop(_gop)
|
||||
}
|
||||
|
||||
func (k *KvmVision) SetFrameDetect(frame uint8) {
|
||||
k.mutex.RLock()
|
||||
defer k.mutex.RUnlock()
|
||||
if k.closed {
|
||||
return
|
||||
}
|
||||
|
||||
_frame := C.uint8_t(frame)
|
||||
C.set_frame_detact(_frame)
|
||||
}
|
||||
|
||||
func (k *KvmVision) Close() {
|
||||
k.mutex.Lock()
|
||||
defer k.mutex.Unlock()
|
||||
if k.closed {
|
||||
return
|
||||
}
|
||||
|
||||
k.closed = true
|
||||
C.kvmv_deinit()
|
||||
log.Debugf("stop kvm vision...")
|
||||
}
|
||||
|
||||
@@ -7,13 +7,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// RegenerateSecretKey regenerate secret key when logout
|
||||
func RegenerateSecretKey() {
|
||||
if instance.JWT.RevokeTokensOnLogout {
|
||||
instance.JWT.SecretKey = generateRandomSecretKey()
|
||||
}
|
||||
}
|
||||
|
||||
// Generate random string for secret key.
|
||||
func generateRandomSecretKey() string {
|
||||
b := make([]byte, 64)
|
||||
|
||||
Binary file not shown.
@@ -14,6 +14,7 @@ require (
|
||||
github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1
|
||||
github.com/pion/dtls/v3 v3.1.2
|
||||
github.com/pion/interceptor v0.1.39
|
||||
github.com/pion/rtp v1.8.18
|
||||
github.com/pion/webrtc/v4 v4.0.1
|
||||
github.com/rs/cors/wrapper/gin v0.0.0-20240830163046-1084d89a1692
|
||||
@@ -21,6 +22,7 @@ require (
|
||||
github.com/spf13/viper v1.19.0
|
||||
github.com/unrolled/secure v1.15.0
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/sys v0.41.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -47,7 +49,6 @@ require (
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pion/datachannel v1.5.9 // indirect
|
||||
github.com/pion/ice/v4 v4.0.2 // indirect
|
||||
github.com/pion/interceptor v0.1.39 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
@@ -79,7 +80,6 @@ require (
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
|
||||
@@ -66,6 +66,7 @@ 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);
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1,68 +1,223 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"NanoKVM-Server/config"
|
||||
)
|
||||
|
||||
const (
|
||||
principalContextKey = "principal"
|
||||
tokenContextKey = "token"
|
||||
CookieName = "nano-kvm-token"
|
||||
sessionRecheckDelay = 5 * time.Second
|
||||
)
|
||||
|
||||
type Principal struct {
|
||||
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 {
|
||||
Username string `json:"username"`
|
||||
Username string `json:"username"`
|
||||
TokenVersion uint64 `json:"tokenVersion"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func CheckToken() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if allowByToken(c) {
|
||||
principal, token, ok := authenticate(c)
|
||||
if !ok {
|
||||
abortUnauthorized(c)
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(principalContextKey, principal)
|
||||
c.Set(tokenContextKey, token)
|
||||
if token == nil || token.ExpiresAt == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
abortUnauthorized(c)
|
||||
requestContext, cancel := context.WithCancel(c.Request.Context())
|
||||
sessionHandle := newSessionHandle()
|
||||
requestContext = WithSessionHandle(requestContext, sessionHandle)
|
||||
c.Request = c.Request.WithContext(requestContext)
|
||||
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() {
|
||||
timer.Stop()
|
||||
unregister()
|
||||
cancel()
|
||||
}()
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func watchSessionState(ctx context.Context, cancel context.CancelFunc, username string, tokenVersion uint64, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if _, err := authn.DefaultStore.ValidateToken(username, tokenVersion); err != nil {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RequireRole(roles ...authn.Role) gin.HandlerFunc {
|
||||
allowed := make(map[authn.Role]struct{}, len(roles))
|
||||
for _, role := range roles {
|
||||
allowed[role] = struct{}{}
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
principal, ok := CurrentPrincipal(c)
|
||||
if !ok {
|
||||
abortUnauthorized(c)
|
||||
return
|
||||
}
|
||||
if _, ok = allowed[principal.Role]; !ok {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
func CurrentPrincipal(c *gin.Context) (Principal, bool) {
|
||||
value, exists := c.Get(principalContextKey)
|
||||
if !exists {
|
||||
return Principal{}, false
|
||||
}
|
||||
principal, ok := value.(Principal)
|
||||
return principal, ok
|
||||
}
|
||||
|
||||
func CheckLoopbackInternalToken() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if allowByLoopbackInternalToken(c.Request) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
abortUnauthorized(c)
|
||||
}
|
||||
}
|
||||
|
||||
func CheckTokenOrLoopbackInternalToken() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if allowByToken(c) || allowByLoopbackInternalToken(c.Request) {
|
||||
if allowByLoopbackInternalToken(c.Request) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
abortUnauthorized(c)
|
||||
principal, token, ok := authenticate(c)
|
||||
if !ok {
|
||||
abortUnauthorized(c)
|
||||
return
|
||||
}
|
||||
c.Set(principalContextKey, principal)
|
||||
c.Set(tokenContextKey, token)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func allowByToken(c *gin.Context) bool {
|
||||
func authenticate(c *gin.Context) (Principal, *Token, bool) {
|
||||
conf := config.GetInstance()
|
||||
|
||||
if conf.Authentication == "disable" {
|
||||
return true
|
||||
return Principal{Username: "admin", Role: authn.RoleAdmin, Permissions: authn.PermissionIDs()}, nil, true
|
||||
}
|
||||
|
||||
cookie, err := c.Cookie("nano-kvm-token")
|
||||
cookie, err := c.Cookie(CookieName)
|
||||
if err != nil {
|
||||
return false
|
||||
return Principal{}, nil, false
|
||||
}
|
||||
|
||||
_, err = ParseJWT(cookie)
|
||||
return err == nil
|
||||
token, err := ParseJWT(cookie)
|
||||
if err != nil {
|
||||
return Principal{}, nil, false
|
||||
}
|
||||
user, err := authn.DefaultStore.ValidateToken(token.Username, token.TokenVersion)
|
||||
if err != nil {
|
||||
log.Debugf("validate session for %q: %s", token.Username, err)
|
||||
return Principal{}, nil, false
|
||||
}
|
||||
return Principal{
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
Permissions: authn.EffectivePermissions(user),
|
||||
}, token, true
|
||||
}
|
||||
|
||||
func abortUnauthorized(c *gin.Context) {
|
||||
@@ -70,37 +225,49 @@ func abortUnauthorized(c *gin.Context) {
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
func GenerateJWT(username string) (string, error) {
|
||||
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()
|
||||
expireDuration := time.Duration(conf.JWT.RefreshTokenDuration) * time.Second
|
||||
|
||||
claims := Token{
|
||||
Username: username,
|
||||
Username: username,
|
||||
TokenVersion: tokenVersion,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expireDuration)),
|
||||
Subject: username,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(expireDuration)),
|
||||
},
|
||||
}
|
||||
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
return t.SignedString([]byte(conf.JWT.SecretKey))
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(conf.JWT.SecretKey))
|
||||
}
|
||||
|
||||
func ParseJWT(jwtToken string) (*Token, error) {
|
||||
conf := config.GetInstance()
|
||||
|
||||
t, err := jwt.ParseWithClaims(jwtToken, &Token{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(conf.JWT.SecretKey), nil
|
||||
})
|
||||
parsed, err := jwt.ParseWithClaims(
|
||||
jwtToken,
|
||||
&Token{},
|
||||
func(token *jwt.Token) (interface{}, error) {
|
||||
if token.Method != jwt.SigningMethodHS256 {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(conf.JWT.SecretKey), nil
|
||||
},
|
||||
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
|
||||
jwt.WithExpirationRequired(),
|
||||
)
|
||||
if err != nil {
|
||||
log.Debugf("parse jwt error: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := t.Claims.(*Token); ok && t.Valid {
|
||||
return claims, nil
|
||||
} else {
|
||||
return nil, err
|
||||
claims, ok := parsed.Claims.(*Token)
|
||||
if !ok || !parsed.Valid || claims.Username == "" || claims.Subject != claims.Username || claims.TokenVersion == 0 {
|
||||
return nil, errors.New("invalid token claims")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
230
server/middleware/jwt_test.go
Normal file
230
server/middleware/jwt_test.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func TestCheckTokenUsesLiveRoleAndVersion(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
|
||||
admin, ok, err := store.Authenticate("admin", "admin")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("default login: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if err = store.Create("alice", "valid-password", authn.RoleUser); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alice, err := store.Get("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
restore := useTestAuthStore(t, store)
|
||||
defer restore()
|
||||
adminToken, err := GenerateJWT(admin.Username, admin.TokenVersion)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userToken, err := GenerateJWT(alice.Username, alice.TokenVersion)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.GET("/admin", CheckToken(), RequireRole(authn.RoleAdmin), func(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
if status := requestWithToken(router, "/admin", ""); status != http.StatusUnauthorized {
|
||||
t.Fatalf("missing token status = %d", status)
|
||||
}
|
||||
if status := requestWithToken(router, "/admin", adminToken); status != http.StatusNoContent {
|
||||
t.Fatalf("admin status = %d", status)
|
||||
}
|
||||
if status := requestWithToken(router, "/admin", userToken); status != http.StatusForbidden {
|
||||
t.Fatalf("user status = %d", status)
|
||||
}
|
||||
if _, err = store.Revoke("admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status := requestWithToken(router, "/admin", adminToken); status != http.StatusUnauthorized {
|
||||
t.Fatalf("revoked status = %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJWTRejectsOtherHMACMethods(t *testing.T) {
|
||||
conf := config.GetInstance()
|
||||
originalSecret := conf.JWT.SecretKey
|
||||
conf.JWT.SecretKey = "test-secret"
|
||||
defer func() { conf.JWT.SecretKey = originalSecret }()
|
||||
|
||||
now := time.Now()
|
||||
claims := Token{
|
||||
Username: "admin",
|
||||
TokenVersion: 1,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: "admin",
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
|
||||
},
|
||||
}
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS384, claims).SignedString([]byte(conf.JWT.SecretKey))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = ParseJWT(token); err == nil {
|
||||
t.Fatal("HS384 token was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJWTRejectsExpiredToken(t *testing.T) {
|
||||
conf := config.GetInstance()
|
||||
originalSecret := conf.JWT.SecretKey
|
||||
conf.JWT.SecretKey = "test-secret"
|
||||
defer func() { conf.JWT.SecretKey = originalSecret }()
|
||||
|
||||
claims := Token{
|
||||
Username: "admin",
|
||||
TokenVersion: 1,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: "admin",
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
|
||||
},
|
||||
}
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(conf.JWT.SecretKey))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = ParseJWT(token); err == nil {
|
||||
t.Fatal("expired token was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeUserSessionsCancelsActiveRequests(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
unregister := activeSessions.register("alice", cancel, newSessionHandle(), "")
|
||||
defer unregister()
|
||||
|
||||
RevokeUserSessions("alice")
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("active session was not cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountFileResetCancelsActiveSession(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pwd")
|
||||
store := authn.NewStore(path)
|
||||
user, ok, err := store.Authenticate("admin", "admin")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("default login: ok=%v err=%v", ok, err)
|
||||
}
|
||||
restore := useTestAuthStore(t, store)
|
||||
defer restore()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go watchSessionState(ctx, cancel, user.Username, user.TokenVersion, 10*time.Millisecond)
|
||||
if err = os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("active session survived account-file reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchWebSocketClosesRevokedConnection(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
connection, err := upgrader.Upgrade(writer, request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer connection.Close()
|
||||
stop := WatchWebSocket(ctx, connection)
|
||||
defer stop()
|
||||
_, _, _ = connection.ReadMessage()
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
wsURL := "ws" + server.URL[len("http"):]
|
||||
connection, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer connection.Close()
|
||||
cancel()
|
||||
_ = connection.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, _, err = connection.ReadMessage()
|
||||
if !websocket.IsCloseError(err, SessionRevokedCloseCode) {
|
||||
t.Fatalf("close error = %v, want code %d", err, SessionRevokedCloseCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckWebSocketOrigin(t *testing.T) {
|
||||
tests := []struct {
|
||||
origin string
|
||||
host string
|
||||
want bool
|
||||
}{
|
||||
{"", "kvm.local", true},
|
||||
{"http://kvm.local", "kvm.local", true},
|
||||
{"http://KVM.local", "kvm.local", true},
|
||||
{"http://kvm.local:8080", "kvm.local:8080", true},
|
||||
{"http://kvm.local:8081", "kvm.local:8080", false},
|
||||
{"http://evil.example", "kvm.local", false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
request := httptest.NewRequest(http.MethodGet, "http://"+test.host+"/api/ws", nil)
|
||||
request.Host = test.host
|
||||
if test.origin != "" {
|
||||
request.Header.Set("Origin", test.origin)
|
||||
}
|
||||
if got := CheckWebSocketOrigin(request); got != test.want {
|
||||
t.Fatalf("origin %q host %q = %v, want %v", test.origin, test.host, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestWithToken(handler http.Handler, path, token string) int {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
request.AddCookie(&http.Cookie{Name: CookieName, Value: token})
|
||||
handler.ServeHTTP(recorder, request)
|
||||
return recorder.Code
|
||||
}
|
||||
|
||||
func useTestAuthStore(t *testing.T, store *authn.Store) func() {
|
||||
t.Helper()
|
||||
originalStore := authn.DefaultStore
|
||||
conf := config.GetInstance()
|
||||
originalAuthentication := conf.Authentication
|
||||
originalSecret := conf.JWT.SecretKey
|
||||
originalDuration := conf.JWT.RefreshTokenDuration
|
||||
authn.DefaultStore = store
|
||||
conf.Authentication = "enable"
|
||||
conf.JWT.SecretKey = "test-secret"
|
||||
conf.JWT.RefreshTokenDuration = 3600
|
||||
return func() {
|
||||
authn.DefaultStore = originalStore
|
||||
conf.Authentication = originalAuthentication
|
||||
conf.JWT.SecretKey = originalSecret
|
||||
conf.JWT.RefreshTokenDuration = originalDuration
|
||||
}
|
||||
}
|
||||
233
server/middleware/session.go
Normal file
233
server/middleware/session.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
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]*sessionEntry
|
||||
}
|
||||
|
||||
var activeSessions = &sessionRegistry{byUserID: make(map[string]map[uint64]*sessionEntry)}
|
||||
|
||||
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]*sessionEntry)
|
||||
}
|
||||
r.byUserID[username][id] = &sessionEntry{cancel: cancel, handle: handle, channel: channel}
|
||||
r.mutex.Unlock()
|
||||
|
||||
return func() {
|
||||
r.mutex.Lock()
|
||||
delete(r.byUserID[username], id)
|
||||
if len(r.byUserID[username]) == 0 {
|
||||
delete(r.byUserID, username)
|
||||
}
|
||||
r.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// 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]
|
||||
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 _, 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(closeCode, reason),
|
||||
time.Now().Add(2*time.Second),
|
||||
)
|
||||
_ = connection.Close()
|
||||
case <-stopped:
|
||||
}
|
||||
}()
|
||||
return func() { stopOnce.Do(func() { close(stopped) }) }
|
||||
}
|
||||
|
||||
func CheckWebSocketOrigin(request *http.Request) bool {
|
||||
origin := strings.TrimSpace(request.Header.Get("Origin"))
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
parsed, err := url.Parse(origin)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return false
|
||||
}
|
||||
requestScheme := "http"
|
||||
if request.TLS != nil {
|
||||
requestScheme = "https"
|
||||
}
|
||||
if forwarded := strings.TrimSpace(strings.Split(request.Header.Get("X-Forwarded-Proto"), ",")[0]); forwarded != "" {
|
||||
requestScheme = strings.ToLower(forwarded)
|
||||
}
|
||||
return equalOrigin(parsed, request.Host, requestScheme)
|
||||
}
|
||||
|
||||
func equalOrigin(origin *url.URL, requestHost, requestScheme string) bool {
|
||||
originHost, originPort := splitHostPort(origin.Host)
|
||||
host, port := splitHostPort(requestHost)
|
||||
if !strings.EqualFold(originHost, host) {
|
||||
return false
|
||||
}
|
||||
if originPort == "" {
|
||||
originPort = defaultPort(origin.Scheme)
|
||||
}
|
||||
if port == "" {
|
||||
port = defaultPort(requestScheme)
|
||||
}
|
||||
return originPort == port
|
||||
}
|
||||
|
||||
func splitHostPort(value string) (string, string) {
|
||||
host, port, err := net.SplitHostPort(value)
|
||||
if err == nil {
|
||||
return host, port
|
||||
}
|
||||
return strings.Trim(value, "[]"), ""
|
||||
}
|
||||
|
||||
func defaultPort(scheme string) string {
|
||||
switch strings.ToLower(scheme) {
|
||||
case "https", "wss":
|
||||
return "443"
|
||||
default:
|
||||
return "80"
|
||||
}
|
||||
}
|
||||
@@ -12,3 +12,13 @@ type GetPreviewRsp struct {
|
||||
type SetPreviewReq struct {
|
||||
Enable bool `validate:"omitempty"`
|
||||
}
|
||||
|
||||
type GetUpdateServerRsp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type SetUpdateServerReq struct {
|
||||
Enabled *bool `json:"enabled" form:"enabled" validate:"required"`
|
||||
URL string `json:"url" form:"url"`
|
||||
}
|
||||
|
||||
@@ -5,19 +5,65 @@ type LoginReq struct {
|
||||
Password string `validate:"required"`
|
||||
}
|
||||
|
||||
type LoginRsp struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type GetAccountRsp struct {
|
||||
Username string `json:"username"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
type ChangePasswordReq struct {
|
||||
Username string `json:"username" validate:"required"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
type IsPasswordUpdatedRsp struct {
|
||||
IsUpdated bool `json:"isUpdated"`
|
||||
}
|
||||
|
||||
type UserInfo struct {
|
||||
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 {
|
||||
Users []UserInfo `json:"users"`
|
||||
}
|
||||
|
||||
type CreateUserReq struct {
|
||||
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"`
|
||||
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
|
||||
}
|
||||
|
||||
92
server/proto/media.go
Normal file
92
server/proto/media.go
Normal file
@@ -0,0 +1,92 @@
|
||||
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
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func ValidateRequest(req interface{}) error {
|
||||
}
|
||||
|
||||
if env == "" || env == "debug" {
|
||||
log.Debugf("request: %+v\n", req)
|
||||
log.Debugf("request validated: %T", req)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -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 {
|
||||
@@ -99,6 +103,7 @@ type GetOLEDRsp struct {
|
||||
|
||||
type GetGetHdmiStateRsp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Signal bool `json:"signal"`
|
||||
IdleTimeout int `json:"idleTimeout"`
|
||||
}
|
||||
|
||||
@@ -151,3 +156,54 @@ type GetWebTitleRsp struct {
|
||||
type SetTlsReq struct {
|
||||
Enabled bool `validate:"omitempty"`
|
||||
}
|
||||
|
||||
type InputRegion struct {
|
||||
Mode string `json:"mode"`
|
||||
FrameWidth int `json:"frameWidth"`
|
||||
FrameHeight int `json:"frameHeight"`
|
||||
Left int `json:"left"`
|
||||
Top int `json:"top"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Resolutions []OriginalResolution `json:"resolutions,omitempty"`
|
||||
SelectedResolution string `json:"selectedResolution"`
|
||||
Regions []ManualRegion `json:"regions,omitempty"`
|
||||
SelectedRegion string `json:"selectedRegion"`
|
||||
}
|
||||
|
||||
type ManualRegion struct {
|
||||
FrameWidth int `json:"frameWidth"`
|
||||
FrameHeight int `json:"frameHeight"`
|
||||
Left int `json:"left"`
|
||||
Top int `json:"top"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
type OriginalResolution struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
type SetInputRegionReq struct {
|
||||
Mode string `json:"mode"`
|
||||
FrameWidth *int `json:"frameWidth,omitempty"`
|
||||
FrameHeight *int `json:"frameHeight,omitempty"`
|
||||
Left *int `json:"left,omitempty"`
|
||||
Top *int `json:"top,omitempty"`
|
||||
Width *int `json:"width,omitempty"`
|
||||
Height *int `json:"height,omitempty"`
|
||||
Resolutions *[]OriginalResolution `json:"resolutions,omitempty"`
|
||||
SelectedResolution *string `json:"selectedResolution,omitempty"`
|
||||
Regions *[]ManualRegion `json:"regions,omitempty"`
|
||||
SelectedRegion *string `json:"selectedRegion,omitempty"`
|
||||
}
|
||||
|
||||
type GetInputRegionRsp struct {
|
||||
InputRegion
|
||||
}
|
||||
|
||||
type GetInputResolutionRsp struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/application"
|
||||
|
||||
@@ -9,7 +10,7 @@ import (
|
||||
|
||||
func applicationRouter(r *gin.Engine) {
|
||||
service := application.NewService()
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
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
|
||||
@@ -17,4 +18,7 @@ func applicationRouter(r *gin.Engine) {
|
||||
|
||||
api.GET("/application/preview", service.GetPreview) // get preview updates state
|
||||
api.POST("/application/preview", service.SetPreview) // set preview updates state
|
||||
|
||||
api.GET("/application/update-server", service.GetUpdateServer) // get custom update server
|
||||
api.POST("/application/update-server", service.SetUpdateServer) // set custom update server
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package router
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/auth"
|
||||
)
|
||||
@@ -18,4 +19,15 @@ func authRouter(r *gin.Engine) {
|
||||
api.GET("/auth/account", service.GetAccount) // get account
|
||||
api.POST("/auth/password", service.ChangePassword) // change password
|
||||
api.POST("/auth/logout", service.Logout) // logout
|
||||
|
||||
admin := r.Group("/api").Use(
|
||||
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)
|
||||
admin.DELETE("/auth/users/:username", service.DeleteUser)
|
||||
admin.POST("/auth/users/:username/password", service.ChangeUserPassword)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/hid"
|
||||
@@ -19,7 +20,10 @@ type setAIControlModeRequest struct {
|
||||
}
|
||||
|
||||
func controlRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *picoclaw.Service) {
|
||||
group := r.Group("/api/ai/control").Use(middleware.CheckToken())
|
||||
group := r.Group("/api/ai/control").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequirePermission(authn.PermAi),
|
||||
)
|
||||
group.GET("/status", func(c *gin.Context) {
|
||||
status, err := control.Status()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/service/download"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"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())
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/extensions/tailscale"
|
||||
|
||||
@@ -8,7 +9,10 @@ import (
|
||||
)
|
||||
|
||||
func extensionsRouter(r *gin.Engine) {
|
||||
api := r.Group("/api/extensions").Use(middleware.CheckToken())
|
||||
api := r.Group("/api/extensions").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequirePermission(authn.PermTailscale),
|
||||
)
|
||||
|
||||
ts := tailscale.NewService()
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package router
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/hid"
|
||||
)
|
||||
@@ -15,22 +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.POST("/hid/shortcut", service.AddShortcut) // add shortcut
|
||||
api.DELETE("/hid/shortcut", service.DeleteShortcut) // delete shortcut
|
||||
view.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
|
||||
view.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // get shortcut leader key
|
||||
|
||||
api.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // set shortcut leader key
|
||||
api.POST("/hid/shortcut/leader-key", service.SetLeaderKey) // set shortcut leader key
|
||||
view.GET("/hid/mode", service.GetHidMode) // get hid mode
|
||||
view.GET("/hid/leds", service.GetKeyboardLedStatus)
|
||||
|
||||
api.GET("/hid/mode", service.GetHidMode) // get hid mode
|
||||
api.POST("/hid/mode", service.SetHidMode) // set hid mode
|
||||
api.POST("/hid/reset", service.ResetHid) // reset hid
|
||||
api.GET("/hid/leds", service.GetKeyboardLedStatus)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package router
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/auth"
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/hid"
|
||||
mcpservice "NanoKVM-Server/service/mcp"
|
||||
@@ -23,7 +25,12 @@ func mcpRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *pic
|
||||
picoclawService.PublishControlModeChangedFrom(status, "mcp_config")
|
||||
},
|
||||
)
|
||||
management := r.Group("/api/mcp").Use(middleware.CheckToken())
|
||||
auth.APIKeyRotator = mcpservice.RotateAPIKey
|
||||
|
||||
management := r.Group("/api/mcp").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequirePermission(authn.PermMcp),
|
||||
)
|
||||
management.GET("/config", service.GetConfig)
|
||||
management.POST("/config", service.SetConfig)
|
||||
management.POST("/key/regenerate", service.RegenerateAPIKey)
|
||||
|
||||
@@ -3,6 +3,7 @@ package router
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/network"
|
||||
)
|
||||
@@ -13,17 +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
|
||||
api.DELETE("/network/wol/mac", service.DeleteMac) // delete mac
|
||||
api.POST("/network/wol/mac/name", service.SetMacName) // set mac name
|
||||
power.POST("/network/wol", service.WakeOnLAN) // wake on lan
|
||||
power.GET("/network/wol/mac", service.GetMac) // get mac list
|
||||
|
||||
api.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
|
||||
api.POST("/network/wifi/connect", service.ConnectWifi) // connect Wi-Fi
|
||||
api.POST("/network/wifi/disconnect", service.DisconnectWifi) // disconnect Wi-Fi
|
||||
power.DELETE("/network/wol/mac", service.DeleteMac) // delete mac
|
||||
power.POST("/network/wol/mac/name", service.SetMacName) // set mac name
|
||||
|
||||
api.GET("/network/dns", service.GetDNS) // get DNS configuration
|
||||
api.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
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package router
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/picoclaw"
|
||||
)
|
||||
@@ -39,7 +40,10 @@ func PicoclawLoopbackHTTPAllowedPaths() []string {
|
||||
}
|
||||
|
||||
func picoclawRouter(r *gin.Engine, service *picoclaw.Service) {
|
||||
frontendAPI := r.Group(picoclawBasePath).Use(middleware.CheckToken())
|
||||
frontendAPI := r.Group(picoclawBasePath).Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequirePermission(authn.PermAi),
|
||||
)
|
||||
localAPI := r.Group(picoclawBasePath).Use(middleware.CheckLoopbackInternalToken())
|
||||
|
||||
localAPI.GET(picoclawScreenshotPath, service.Screenshot)
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"NanoKVM-Server/service/auth"
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/picoclaw"
|
||||
"NanoKVM-Server/service/ws"
|
||||
|
||||
"github.com/gin-gonic/contrib/static"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -35,6 +37,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,19 +1,49 @@
|
||||
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())
|
||||
// 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/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
|
||||
|
||||
@@ -3,6 +3,7 @@ package router
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/vm"
|
||||
)
|
||||
@@ -10,64 +11,95 @@ import (
|
||||
func vmRouter(r *gin.Engine) {
|
||||
service := vm.NewService()
|
||||
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
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/terminal", service.Terminal) // web terminal
|
||||
// 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
|
||||
|
||||
api.GET("/vm/script", service.GetScripts) // get script
|
||||
api.POST("/vm/script/upload", service.UploadScript) // upload script
|
||||
api.POST("/vm/script/run", service.RunScript) // run script
|
||||
api.DELETE("/vm/script", service.DeleteScript) // delete script
|
||||
view.GET("/vm/input-region", service.GetInputRegion)
|
||||
control.POST("/vm/input-region", service.SetInputRegion)
|
||||
view.GET("/vm/input-resolution", service.GetInputResolution)
|
||||
|
||||
api.GET("/vm/device/virtual", service.GetVirtualDevice) // get virtual device
|
||||
api.POST("/vm/device/virtual", service.UpdateVirtualDevice) // update virtual device
|
||||
terminal.GET("/vm/terminal", service.Terminal) // web terminal
|
||||
|
||||
api.GET("/vm/memory/limit", service.GetMemoryLimit) // get memory limit
|
||||
api.POST("/vm/memory/limit", service.SetMemoryLimit) // set memory limit
|
||||
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
|
||||
|
||||
api.GET("/vm/oled", service.GetOLED) // get OLED configuration
|
||||
api.POST("/vm/oled", service.SetOLED) // set OLED configuration
|
||||
// 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
|
||||
|
||||
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
|
||||
api.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi
|
||||
api.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi
|
||||
api.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)
|
||||
|
||||
api.GET("/vm/ssh", service.GetSSHState) // get SSH state
|
||||
api.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH
|
||||
api.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
|
||||
|
||||
api.GET("/vm/swap", service.GetSwap) // get swap file size
|
||||
api.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
|
||||
|
||||
api.GET("/vm/mouse-jiggler", service.GetMouseJiggler) // get mouse jiggler
|
||||
api.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
|
||||
api.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
|
||||
api.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
|
||||
|
||||
api.GET("/vm/mdns", service.GetMdnsState) // get mDNS state
|
||||
api.POST("/vm/mdns/enable", service.EnableMdns) // enable mDNS
|
||||
api.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
|
||||
|
||||
api.POST("/vm/tls", service.SetTls) // enable/disable TLS
|
||||
network.POST("/vm/tls", service.SetTls) // enable/disable TLS
|
||||
|
||||
api.GET("/vm/autostart", service.GetAutostart) // get autostart list
|
||||
api.GET("/vm/autostart/:name", service.GetAutostartContent) // get autostart content
|
||||
api.DELETE("/vm/autostart/:name", service.DeleteAutostart) // delete autostart script
|
||||
api.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
|
||||
|
||||
api.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)
|
||||
}
|
||||
|
||||
203
server/service/application/archive.go
Normal file
203
server/service/application/archive.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type updateArchiveInfo struct {
|
||||
root string
|
||||
expandedBytes uint64
|
||||
entries int
|
||||
}
|
||||
|
||||
func inspectUpdateArchive(archivePath, expectedRoot string) (updateArchiveInfo, error) {
|
||||
file, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return updateArchiveInfo{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
reader, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return updateArchiveInfo{}, fmt.Errorf("open gzip archive: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
info := updateArchiveInfo{root: expectedRoot}
|
||||
seen := make(map[string]struct{})
|
||||
hasRoot, hasVersion := false, false
|
||||
tarReader := tar.NewReader(reader)
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return updateArchiveInfo{}, fmt.Errorf("read tar archive: %w", err)
|
||||
}
|
||||
name, err := validateArchiveHeader(header, expectedRoot)
|
||||
if err != nil {
|
||||
return updateArchiveInfo{}, err
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
return updateArchiveInfo{}, fmt.Errorf("duplicate archive entry %q", name)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
info.entries++
|
||||
if info.entries > maxArchiveEntries {
|
||||
return updateArchiveInfo{}, fmt.Errorf("update archive has more than %d entries", maxArchiveEntries)
|
||||
}
|
||||
if name == expectedRoot && header.Typeflag == tar.TypeDir {
|
||||
hasRoot = true
|
||||
}
|
||||
if name == expectedRoot+"/version" && isRegularType(header.Typeflag) {
|
||||
hasVersion = true
|
||||
}
|
||||
if isRegularType(header.Typeflag) {
|
||||
if header.Size < 0 {
|
||||
return updateArchiveInfo{}, fmt.Errorf("negative archive file size")
|
||||
}
|
||||
size := uint64(header.Size)
|
||||
if size > maxExpandedSize-info.expandedBytes {
|
||||
return updateArchiveInfo{}, fmt.Errorf("update archive expanded size exceeds %d bytes", maxExpandedSize)
|
||||
}
|
||||
if info.expandedBytes > math.MaxUint64-size {
|
||||
return updateArchiveInfo{}, fmt.Errorf("update archive expanded size overflow")
|
||||
}
|
||||
info.expandedBytes += size
|
||||
}
|
||||
}
|
||||
if !hasRoot {
|
||||
return updateArchiveInfo{}, fmt.Errorf("invalid update package layout: missing top-level directory")
|
||||
}
|
||||
if !hasVersion {
|
||||
return updateArchiveInfo{}, fmt.Errorf("invalid update package layout: missing version file")
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func extractUpdateArchive(archivePath, destDir, expectedRoot string) (string, error) {
|
||||
file, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
reader, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open gzip archive: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
entries := 0
|
||||
expanded := uint64(0)
|
||||
tarReader := tar.NewReader(reader)
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read tar archive: %w", err)
|
||||
}
|
||||
name, err := validateArchiveHeader(header, expectedRoot)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
return "", fmt.Errorf("duplicate archive entry %q", name)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
entries++
|
||||
if entries > maxArchiveEntries {
|
||||
return "", fmt.Errorf("update archive has more than %d entries", maxArchiveEntries)
|
||||
}
|
||||
|
||||
filename := filepath.Join(destDir, filepath.FromSlash(name))
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(filename, 0o755); err != nil {
|
||||
return "", fmt.Errorf("create archive directory: %w", err)
|
||||
}
|
||||
case tar.TypeReg, tar.TypeRegA:
|
||||
if header.Size < 0 {
|
||||
return "", fmt.Errorf("negative archive file size")
|
||||
}
|
||||
size := uint64(header.Size)
|
||||
if size > maxExpandedSize-expanded {
|
||||
return "", fmt.Errorf("update archive expanded size exceeds %d bytes", maxExpandedSize)
|
||||
}
|
||||
expanded += size
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil {
|
||||
return "", fmt.Errorf("create archive parent directory: %w", err)
|
||||
}
|
||||
out, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_EXCL, os.FileMode(header.Mode)&0o777)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create archive file: %w", err)
|
||||
}
|
||||
written, copyErr := io.CopyN(out, tarReader, header.Size)
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil {
|
||||
return "", fmt.Errorf("write archive file: %w", copyErr)
|
||||
}
|
||||
if written != header.Size {
|
||||
return "", fmt.Errorf("truncated archive file %q", name)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return "", fmt.Errorf("close archive file: %w", closeErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return filepath.Join(destDir, expectedRoot), nil
|
||||
}
|
||||
|
||||
func validateArchiveHeader(header *tar.Header, expectedRoot string) (string, error) {
|
||||
if header.Name == "" || path.IsAbs(header.Name) {
|
||||
return "", fmt.Errorf("invalid archive path %q", header.Name)
|
||||
}
|
||||
name := path.Clean(header.Name)
|
||||
if name == "." || name == ".." || strings.HasPrefix(name, "../") {
|
||||
return "", fmt.Errorf("invalid archive path %q", header.Name)
|
||||
}
|
||||
if name != expectedRoot && !strings.HasPrefix(name, expectedRoot+"/") {
|
||||
return "", fmt.Errorf("invalid update package layout: entry %q is outside %s", header.Name, expectedRoot)
|
||||
}
|
||||
if !isRegularType(header.Typeflag) && header.Typeflag != tar.TypeDir {
|
||||
return "", fmt.Errorf("unsupported archive entry type for %q", header.Name)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func isRegularType(typeFlag byte) bool {
|
||||
return typeFlag == tar.TypeReg || typeFlag == tar.TypeRegA
|
||||
}
|
||||
|
||||
func validateExtractedPackage(rootDir, expectedVersion string) error {
|
||||
version, err := os.ReadFile(filepath.Join(rootDir, "version"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid update package layout: read version: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(string(version)) != expectedVersion {
|
||||
return fmt.Errorf("invalid update package layout: version mismatch")
|
||||
}
|
||||
for _, required := range []string{
|
||||
"version",
|
||||
"server/NanoKVM-Server",
|
||||
"kvm_system/kvm_system",
|
||||
"system/init.d/S95nanokvm",
|
||||
} {
|
||||
info, err := os.Stat(filepath.Join(rootDir, filepath.FromSlash(required)))
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("invalid update package layout: missing required file %s", required)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
90
server/service/application/archive_test.go
Normal file
90
server/service/application/archive_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type archiveEntry struct {
|
||||
name string
|
||||
typeFlag byte
|
||||
data string
|
||||
}
|
||||
|
||||
func writeTestArchive(t *testing.T, entries []archiveEntry) string {
|
||||
t.Helper()
|
||||
file := filepath.Join(t.TempDir(), "update.tar.gz")
|
||||
out, err := os.Create(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gzipWriter := gzip.NewWriter(out)
|
||||
tarWriter := tar.NewWriter(gzipWriter)
|
||||
for _, entry := range entries {
|
||||
header := &tar.Header{Name: entry.name, Typeflag: entry.typeFlag, Mode: 0o755}
|
||||
if entry.typeFlag == tar.TypeReg || entry.typeFlag == tar.TypeRegA {
|
||||
header.Size = int64(len(entry.data))
|
||||
}
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entry.data != "" {
|
||||
if _, err := tarWriter.Write([]byte(entry.data)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gzipWriter.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
func TestInspectArchiveRejectsUnsafeEntries(t *testing.T) {
|
||||
root := "nanokvm_1.2.3"
|
||||
for _, entry := range []archiveEntry{
|
||||
{name: "../escape", typeFlag: tar.TypeReg, data: "x"},
|
||||
{name: "/escape", typeFlag: tar.TypeReg, data: "x"},
|
||||
{name: root + "/link", typeFlag: tar.TypeSymlink},
|
||||
} {
|
||||
archive := writeTestArchive(t, []archiveEntry{{name: root, typeFlag: tar.TypeDir}, entry})
|
||||
if _, err := inspectUpdateArchive(archive, root); err == nil {
|
||||
t.Fatalf("unsafe entry %+v was accepted", entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectAndExtractArchive(t *testing.T) {
|
||||
root := "nanokvm_1.2.3"
|
||||
archive := writeTestArchive(t, []archiveEntry{
|
||||
{name: root, typeFlag: tar.TypeDir},
|
||||
{name: root + "/version", typeFlag: tar.TypeReg, data: "1.2.3\n"},
|
||||
{name: root + "/server/NanoKVM-Server", typeFlag: tar.TypeReg, data: "server"},
|
||||
{name: root + "/kvm_system/kvm_system", typeFlag: tar.TypeReg, data: "system"},
|
||||
{name: root + "/system/init.d/S95nanokvm", typeFlag: tar.TypeReg, data: "init"},
|
||||
})
|
||||
info, err := inspectUpdateArchive(archive, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.expandedBytes != 22 { // data totals: 6 + 6 + 6 + 4
|
||||
t.Fatalf("unexpected expanded size %d", info.expandedBytes)
|
||||
}
|
||||
destination := t.TempDir()
|
||||
extracted, err := extractUpdateArchive(archive, destination, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateExtractedPackage(extracted, "1.2.3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -31,17 +31,12 @@ func releaseUpdateLock() {
|
||||
isUpdating = false
|
||||
}
|
||||
|
||||
func installPackage(source string) error {
|
||||
dir, err := utils.UnTarGz(source, CacheDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decompress app: %w", err)
|
||||
}
|
||||
|
||||
func installPreparedPackage(sourceDir string) error {
|
||||
if err := backupCurrentApp(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := applyUpdate(dir); err != nil {
|
||||
if err := applyUpdate(sourceDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/utils"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
sentinelPath = "/tmp/.download_in_progress"
|
||||
sentinelPath = utils.TransferSentinelPath
|
||||
tickerInterval = 2500 * time.Millisecond
|
||||
sentinelPermission = 0644
|
||||
)
|
||||
|
||||
@@ -7,6 +7,14 @@ const (
|
||||
AppDir = "/kvmapp"
|
||||
BackupDir = "/root/old"
|
||||
CacheDir = "/root/.kvmcache"
|
||||
|
||||
updateWorkspacePrefix = "nanokvm-update-"
|
||||
cacheDirMode = 0o700
|
||||
maxPackageSize = uint64(1 << 30)
|
||||
maxExpandedSize = uint64(2 << 30)
|
||||
maxArchiveEntries = 100_000
|
||||
minFreeReserve = uint64(128 << 20)
|
||||
freeReservePercent = uint64(5)
|
||||
)
|
||||
|
||||
type Service struct{}
|
||||
|
||||
103
server/service/application/storage.go
Normal file
103
server/service/application/storage.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var ErrInsufficientStorage = errors.New("insufficient storage")
|
||||
|
||||
type filesystemSpace struct {
|
||||
total uint64
|
||||
available uint64
|
||||
}
|
||||
|
||||
func getFilesystemSpace(path string) (filesystemSpace, error) {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs(path, &stat); err != nil {
|
||||
return filesystemSpace{}, fmt.Errorf("stat filesystem %s: %w", path, err)
|
||||
}
|
||||
return filesystemSpaceFromStats(uint64(stat.Blocks), uint64(stat.Bavail), uint64(stat.Bsize))
|
||||
}
|
||||
|
||||
func filesystemSpaceFromStats(blocks, availableBlocks, blockSize uint64) (filesystemSpace, error) {
|
||||
if blockSize != 0 && (blocks > math.MaxUint64/blockSize || availableBlocks > math.MaxUint64/blockSize) {
|
||||
return filesystemSpace{}, fmt.Errorf("filesystem size overflow")
|
||||
}
|
||||
return filesystemSpace{total: blocks * blockSize, available: availableBlocks * blockSize}, nil
|
||||
}
|
||||
|
||||
func reserveBytes(total uint64) uint64 {
|
||||
percent := total / 100 * freeReservePercent
|
||||
percent += total % 100 * freeReservePercent / 100
|
||||
if percent > minFreeReserve {
|
||||
return percent
|
||||
}
|
||||
return minFreeReserve
|
||||
}
|
||||
|
||||
func hasFreeSpace(space filesystemSpace, payload uint64) (bool, uint64, error) {
|
||||
reserve := reserveBytes(space.total)
|
||||
if payload > math.MaxUint64-reserve {
|
||||
return false, 0, fmt.Errorf("storage requirement overflow")
|
||||
}
|
||||
required := payload + reserve
|
||||
return space.available >= required, required, nil
|
||||
}
|
||||
|
||||
func ensureFreeSpace(path string, payloadBytes uint64) error {
|
||||
space, err := getFilesystemSpace(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ok, required, err := hasFreeSpace(space, payloadBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: need %d MiB including reserve, %d MiB available on %s", ErrInsufficientStorage, required>>20, space.available>>20, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureExpandedSpace(path string, expandedBytes uint64) error {
|
||||
// Keep the last known-good backup until the new package is fully prepared.
|
||||
// Reclaiming it here can leave the device without rollback material even
|
||||
// when extraction later fails or the reclaimed space is still insufficient.
|
||||
return ensureFreeSpace(path, expandedBytes)
|
||||
}
|
||||
|
||||
func ensureInstallFilesystem(workspaceDir, appDir, backupDir string) error {
|
||||
backupPath := backupDir
|
||||
if _, err := os.Stat(backupPath); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("stat backup path %s: %w", backupPath, err)
|
||||
}
|
||||
backupPath = filepath.Dir(backupPath)
|
||||
}
|
||||
|
||||
// AppDir already exists (prepareCacheForUpdate verifies it), so stat the
|
||||
// directory itself. Its parent can be on another filesystem when AppDir is
|
||||
// a mount point.
|
||||
paths := []string{workspaceDir, appDir, backupPath}
|
||||
var device uint64
|
||||
for i, path := range paths {
|
||||
var stat unix.Stat_t
|
||||
if err := unix.Stat(path, &stat); err != nil {
|
||||
return fmt.Errorf("stat install filesystem %s: %w", path, err)
|
||||
}
|
||||
if i == 0 {
|
||||
device = uint64(stat.Dev)
|
||||
continue
|
||||
}
|
||||
if uint64(stat.Dev) != device {
|
||||
return fmt.Errorf("update workspace, application and backup must be on the same filesystem")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
46
server/service/application/storage_test.go
Normal file
46
server/service/application/storage_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"math"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReserveBytesAndSpaceBoundary(t *testing.T) {
|
||||
if got := reserveBytes(1 << 30); got != minFreeReserve {
|
||||
t.Fatalf("reserve for 1 GiB = %d, want %d", got, minFreeReserve)
|
||||
}
|
||||
if got := reserveBytes(4 << 30); got != 214748364 {
|
||||
t.Fatalf("reserve for 4 GiB = %d, want 214748364", got)
|
||||
}
|
||||
space := filesystemSpace{total: 1 << 30, available: (1 << 20) + minFreeReserve}
|
||||
ok, required, err := hasFreeSpace(space, 1<<20)
|
||||
if err != nil || !ok || required != space.available {
|
||||
t.Fatalf("boundary should fit: ok=%v required=%d err=%v", ok, required, err)
|
||||
}
|
||||
space.available--
|
||||
ok, _, err = hasFreeSpace(space, 1<<20)
|
||||
if err != nil || ok {
|
||||
t.Fatalf("one byte short should fail: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageOverflowIsRejected(t *testing.T) {
|
||||
if _, err := filesystemSpaceFromStats(math.MaxUint64, 1, 2); err == nil {
|
||||
t.Fatal("expected filesystem overflow")
|
||||
}
|
||||
if _, _, err := hasFreeSpace(filesystemSpace{total: 1}, math.MaxUint64); err == nil {
|
||||
t.Fatal("expected requirement overflow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallFilesystemChecksAppMountPoint(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
backup := filepath.Join(t.TempDir(), "backup")
|
||||
|
||||
// /proc is a mount point whose parent is /. Checking filepath.Dir(appDir)
|
||||
// would therefore miss the cross-filesystem layout on a normal Linux host.
|
||||
if err := ensureInstallFilesystem(workspace, "/proc", backup); err == nil {
|
||||
t.Fatal("separate application mount point was accepted")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -28,9 +30,9 @@ func (s *Service) Update(c *gin.Context) {
|
||||
rsp.ErrRsp(c, -1, "update already in progress")
|
||||
return
|
||||
}
|
||||
defer releaseUpdateLock()
|
||||
|
||||
if err := update(); err != nil {
|
||||
releaseUpdateLock()
|
||||
rsp.ErrRsp(c, -1, fmt.Sprintf("update failed: %s", err))
|
||||
return
|
||||
}
|
||||
@@ -38,40 +40,75 @@ func (s *Service) Update(c *gin.Context) {
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("update application success")
|
||||
|
||||
// Sleep for a second before restarting the device
|
||||
go restartServices()
|
||||
}
|
||||
|
||||
func restartServices() {
|
||||
// Let the HTTP response reach the client before stopping the server.
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
_ = exec.Command("sh", "-c", "/etc/init.d/S95nanokvm restart").Run()
|
||||
if err := exec.Command("/kvmapp/system/init.d/S95nanokvm", "restart").Run(); err != nil {
|
||||
log.Errorf("failed to restart services after update: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func update() error {
|
||||
_ = os.RemoveAll(CacheDir)
|
||||
_ = os.MkdirAll(CacheDir, 0o755)
|
||||
defer func() {
|
||||
_ = os.RemoveAll(CacheDir)
|
||||
}()
|
||||
|
||||
// get latest information
|
||||
latest, err := getLatest()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// download
|
||||
target := fmt.Sprintf("%s/%s", CacheDir, latest.Name)
|
||||
if err := download(latest.Url, target); err != nil {
|
||||
log.Errorf("download app failed: %s", err)
|
||||
if err := prepareCacheForUpdate(); err != nil {
|
||||
return err
|
||||
}
|
||||
workspace, err := newUpdateWorkspace(CacheDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := workspace.Close(); err != nil {
|
||||
log.Warnf("failed to clean update workspace %s: %v", workspace.dir, err)
|
||||
}
|
||||
}()
|
||||
if err := ensureInstallFilesystem(workspace.dir, AppDir, BackupDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := preflightManifestSpace(workspace.dir, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target := filepath.Join(workspace.dir, latest.Name)
|
||||
downloadInfo, err := download(latest, target)
|
||||
if err != nil {
|
||||
log.Errorf("download app failed: %s", err)
|
||||
return err
|
||||
}
|
||||
if err := validateDownloadedSize(latest, uint64(downloadInfo.Written)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check sha512
|
||||
if err := checksum(target, latest.Sha512); err != nil {
|
||||
log.Errorf("check sha512 failed: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// install
|
||||
if err := installPackage(target); err != nil {
|
||||
expectedRoot := strings.TrimSuffix(latest.Name, ".tar.gz")
|
||||
info, err := inspectUpdateArchive(target, expectedRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect update package: %w", err)
|
||||
}
|
||||
if err := validateExpandedSize(latest, info.expandedBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureExpandedSpace(workspace.dir, info.expandedBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
sourceDir, err := extractUpdateArchive(target, workspace.dir, expectedRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extract update package: %w", err)
|
||||
}
|
||||
if err := validateExtractedPackage(sourceDir, latest.Version); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := installPreparedPackage(sourceDir); err != nil {
|
||||
log.Errorf("failed to install package: %v", err)
|
||||
return err
|
||||
}
|
||||
@@ -79,7 +116,7 @@ func update() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func download(url string, target string) (err error) {
|
||||
func download(latest *Latest, target string) (info utils.DownloadInfo, err error) {
|
||||
for i := range maxTries {
|
||||
log.Debugf("attempt #%d/%d", i+1, maxTries)
|
||||
if i > 0 {
|
||||
@@ -87,21 +124,26 @@ func download(url string, target string) (err error) {
|
||||
}
|
||||
|
||||
var req *http.Request
|
||||
req, err = http.NewRequest("GET", url, nil)
|
||||
req, err = utils.NewAuthenticatedRequest("GET", latest.Url, nil)
|
||||
if err != nil {
|
||||
log.Errorf("new request err: %s", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debugf("update will be saved to: %s", target)
|
||||
err = utils.Download(req, target)
|
||||
info, err = utils.Download(req, target, int64(maxPackageSize), func(contentLength int64) error {
|
||||
if latest.ManifestVersion == 2 && uint64(contentLength) != latest.SizeBytes {
|
||||
return fmt.Errorf("update package size mismatch: manifest has %d bytes, response has %d", latest.SizeBytes, contentLength)
|
||||
}
|
||||
return ensureFreeSpace(filepath.Dir(target), uint64(contentLength))
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("downloading latest application failed, try again...")
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
return info, nil
|
||||
}
|
||||
return err
|
||||
return utils.DownloadInfo{}, err
|
||||
}
|
||||
|
||||
func checksum(filePath string, expectedHash string) error {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -25,9 +27,9 @@ func (s *Service) OfflineUpdate(c *gin.Context) {
|
||||
rsp.ErrRsp(c, -1, "update already in progress")
|
||||
return
|
||||
}
|
||||
defer releaseUpdateLock()
|
||||
|
||||
if err := offlineUpdate(c); err != nil {
|
||||
releaseUpdateLock()
|
||||
rsp.ErrRsp(c, -1, fmt.Sprintf("update failed: %s", err))
|
||||
return
|
||||
}
|
||||
@@ -35,16 +37,42 @@ func (s *Service) OfflineUpdate(c *gin.Context) {
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("offline update application success")
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
_ = exec.Command("sh", "-c", "/etc/init.d/S95nanokvm restart").Run()
|
||||
go restartServices()
|
||||
}
|
||||
|
||||
func offlineUpdate(c *gin.Context) error {
|
||||
_ = os.RemoveAll(CacheDir)
|
||||
_ = os.MkdirAll(CacheDir, 0o755)
|
||||
expectedSHA256, err := parseSHA256Checksum(c.GetHeader("X-SHA256-Checksum"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := prepareCacheForUpdate(); err != nil {
|
||||
return err
|
||||
}
|
||||
workspace, err := newUpdateWorkspace(CacheDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = os.RemoveAll(CacheDir)
|
||||
if err := workspace.Close(); err != nil {
|
||||
log.Warnf("failed to clean update workspace %s: %v", workspace.dir, err)
|
||||
}
|
||||
}()
|
||||
if err := ensureInstallFilesystem(workspace.dir, AppDir, BackupDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
maxRequestBytes := int64(maxPackageSize) + (1 << 20)
|
||||
contentLength := c.Request.ContentLength
|
||||
if contentLength > maxRequestBytes {
|
||||
return fmt.Errorf("offline update request exceeds %d bytes", maxRequestBytes)
|
||||
}
|
||||
if contentLength > 0 {
|
||||
if err := ensureFreeSpace(workspace.dir, uint64(contentLength)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxRequestBytes)
|
||||
|
||||
if err := createSentinelFile(); err != nil {
|
||||
return err
|
||||
@@ -57,13 +85,35 @@ func offlineUpdate(c *gin.Context) error {
|
||||
return fmt.Errorf("invalid multipart data: %w", err)
|
||||
}
|
||||
|
||||
target, err := processUpload(reader, c.Request.ContentLength)
|
||||
target, err := processUpload(reader, contentLength, workspace.dir)
|
||||
if err != nil {
|
||||
log.Errorf("failed to upload install package: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := installPackage(target); err != nil {
|
||||
if err := verifySHA256Checksum(target, expectedSHA256); err != nil {
|
||||
log.Errorf("failed to verify install package: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
archiveName := filepath.Base(target)
|
||||
expectedRoot := strings.TrimSuffix(archiveName, ".tar.gz")
|
||||
expectedVersion := strings.TrimPrefix(expectedRoot, "nanokvm_")
|
||||
info, err := inspectUpdateArchive(target, expectedRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect update package: %w", err)
|
||||
}
|
||||
if err := ensureExpandedSpace(workspace.dir, info.expandedBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
sourceDir, err := extractUpdateArchive(target, workspace.dir, expectedRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extract update package: %w", err)
|
||||
}
|
||||
if err := validateExtractedPackage(sourceDir, expectedVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := installPreparedPackage(sourceDir); err != nil {
|
||||
log.Errorf("failed to install package: %v", err)
|
||||
return err
|
||||
}
|
||||
@@ -71,6 +121,43 @@ func offlineUpdate(c *gin.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseSHA256Checksum(value string) ([]byte, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
checksum, err := hex.DecodeString(value)
|
||||
if err != nil || len(checksum) != sha256.Size {
|
||||
return nil, fmt.Errorf("invalid sha256 checksum")
|
||||
}
|
||||
|
||||
return checksum, nil
|
||||
}
|
||||
|
||||
func verifySHA256Checksum(filePath string, expected []byte) error {
|
||||
if len(expected) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open uploaded file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
return fmt.Errorf("failed to calculate sha256 checksum: %w", err)
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare(hasher.Sum(nil), expected) != 1 {
|
||||
return fmt.Errorf("sha256 checksum mismatch")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createSentinelFile() error {
|
||||
file, err := os.OpenFile(
|
||||
sentinelPath,
|
||||
@@ -98,7 +185,7 @@ func createSentinelFile() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func processUpload(reader *multipart.Reader, contentLength int64) (string, error) {
|
||||
func processUpload(reader *multipart.Reader, contentLength int64, workspaceDir string) (string, error) {
|
||||
var outPath string
|
||||
|
||||
for {
|
||||
@@ -113,8 +200,11 @@ func processUpload(reader *multipart.Reader, contentLength int64) (string, error
|
||||
if part.FormName() != "file" {
|
||||
continue
|
||||
}
|
||||
if outPath != "" {
|
||||
return "", fmt.Errorf("multiple files uploaded")
|
||||
}
|
||||
|
||||
outPath, err = saveUploadedFile(part, contentLength)
|
||||
outPath, err = saveUploadedFile(part, contentLength, workspaceDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -127,7 +217,7 @@ func processUpload(reader *multipart.Reader, contentLength int64) (string, error
|
||||
return outPath, nil
|
||||
}
|
||||
|
||||
func saveUploadedFile(part *multipart.Part, contentLength int64) (string, error) {
|
||||
func saveUploadedFile(part *multipart.Part, contentLength int64, workspaceDir string) (string, error) {
|
||||
filename := part.FileName()
|
||||
if filename == "" {
|
||||
return "", fmt.Errorf("no filename provided")
|
||||
@@ -137,19 +227,36 @@ func saveUploadedFile(part *multipart.Part, contentLength int64) (string, error)
|
||||
return "", err
|
||||
}
|
||||
|
||||
outPath := filepath.Join(CacheDir, filename)
|
||||
out, err := os.Create(outPath)
|
||||
outPath := filepath.Join(workspaceDir, filename)
|
||||
out, err := os.OpenFile(outPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
success := false
|
||||
defer func() {
|
||||
_ = out.Close()
|
||||
if !success {
|
||||
_ = os.Remove(outPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if contentLength < 0 {
|
||||
contentLength = 0
|
||||
}
|
||||
pw := newProgressWriter(out, contentLength)
|
||||
defer pw.Stop()
|
||||
|
||||
if _, err := io.Copy(pw, part); err != nil {
|
||||
written, err := io.Copy(pw, io.LimitReader(part, int64(maxPackageSize)+1))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
if written > int64(maxPackageSize) {
|
||||
return "", fmt.Errorf("uploaded package exceeds %d bytes", maxPackageSize)
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return "", fmt.Errorf("failed to close output file: %w", err)
|
||||
}
|
||||
success = true
|
||||
|
||||
return outPath, nil
|
||||
}
|
||||
@@ -174,6 +281,9 @@ func validateFilename(filename string) error {
|
||||
log.Warnf("Invalid filename characters: %s", filename)
|
||||
return fmt.Errorf("invalid filename: contains invalid characters")
|
||||
}
|
||||
if !packageNamePattern.MatchString(filename) {
|
||||
return fmt.Errorf("invalid update package name")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
212
server/service/application/update_server.go
Normal file
212
server/service/application/update_server.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
UpdateServerConfigFile = "/etc/kvm/application-update.json"
|
||||
maxUpdateServerURLSize = 2048
|
||||
)
|
||||
|
||||
var (
|
||||
updateServerConfigMu sync.Mutex
|
||||
updateServerConfigPath = UpdateServerConfigFile
|
||||
)
|
||||
|
||||
type UpdateServerConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func defaultUpdateServerConfig() UpdateServerConfig {
|
||||
return UpdateServerConfig{URL: StableURL}
|
||||
}
|
||||
|
||||
func (s *Service) GetUpdateServer(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
cfg, err := loadUpdateServerConfig()
|
||||
if err != nil {
|
||||
log.Errorf("failed to load update server config: %s", err)
|
||||
rsp.ErrRsp(c, -1, "failed to load update server config")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetUpdateServerRsp{
|
||||
Enabled: cfg.Enabled,
|
||||
URL: cfg.URL,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) SetUpdateServer(c *gin.Context) {
|
||||
var req proto.SetUpdateServerReq
|
||||
var rsp proto.Response
|
||||
|
||||
// Avoid the shared request logger because the URL may contain credentials.
|
||||
if err := c.ShouldBind(&req); err != nil || req.Enabled == nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
normalizedURL, err := normalizeUpdateServerURL(req.URL)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, err.Error())
|
||||
return
|
||||
}
|
||||
if *req.Enabled && normalizedURL == "" {
|
||||
rsp.ErrRsp(c, -2, "update server URL is required")
|
||||
return
|
||||
}
|
||||
if normalizedURL == "" {
|
||||
normalizedURL = StableURL
|
||||
}
|
||||
|
||||
cfg := UpdateServerConfig{Enabled: *req.Enabled, URL: normalizedURL}
|
||||
if err := saveUpdateServerConfig(cfg); err != nil {
|
||||
log.Errorf("failed to save update server config: %s", err)
|
||||
rsp.ErrRsp(c, -3, "failed to save update server config")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetUpdateServerRsp{
|
||||
Enabled: cfg.Enabled,
|
||||
URL: cfg.URL,
|
||||
})
|
||||
}
|
||||
|
||||
func resolveUpdateBaseURL() (string, error) {
|
||||
cfg, err := loadUpdateServerConfig()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if cfg.Enabled {
|
||||
return cfg.URL, nil
|
||||
}
|
||||
if isPreviewEnabled() {
|
||||
return PreviewURL, nil
|
||||
}
|
||||
return StableURL, nil
|
||||
}
|
||||
|
||||
func normalizeUpdateServerURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(raw) > maxUpdateServerURLSize {
|
||||
return "", errors.New("update server URL is too long")
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return "", errors.New("invalid update server URL")
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", errors.New("update server URL must not contain a query or fragment")
|
||||
}
|
||||
if strings.HasSuffix(strings.TrimRight(parsed.Path, "/"), "/latest.json") {
|
||||
return "", errors.New("enter the update server directory, not latest.json")
|
||||
}
|
||||
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func loadUpdateServerConfig() (UpdateServerConfig, error) {
|
||||
updateServerConfigMu.Lock()
|
||||
defer updateServerConfigMu.Unlock()
|
||||
|
||||
return loadUpdateServerConfigFromPath(updateServerConfigPath)
|
||||
}
|
||||
|
||||
func loadUpdateServerConfigFromPath(path string) (UpdateServerConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return defaultUpdateServerConfig(), nil
|
||||
}
|
||||
return UpdateServerConfig{}, err
|
||||
}
|
||||
|
||||
var cfg UpdateServerConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return UpdateServerConfig{}, fmt.Errorf("decode update server config: %w", err)
|
||||
}
|
||||
|
||||
normalizedURL, err := normalizeUpdateServerURL(cfg.URL)
|
||||
if err != nil {
|
||||
return UpdateServerConfig{}, err
|
||||
}
|
||||
if normalizedURL == "" {
|
||||
normalizedURL = StableURL
|
||||
}
|
||||
cfg.URL = normalizedURL
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func saveUpdateServerConfig(cfg UpdateServerConfig) error {
|
||||
updateServerConfigMu.Lock()
|
||||
defer updateServerConfigMu.Unlock()
|
||||
|
||||
return saveUpdateServerConfigToPath(updateServerConfigPath, cfg)
|
||||
}
|
||||
|
||||
func saveUpdateServerConfigToPath(path string, cfg UpdateServerConfig) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create update server config directory: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode update server config: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
tmp, err := os.CreateTemp(dir, ".application-update.json.*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary update server config: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpPath) }()
|
||||
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("set update server config permissions: %w", err)
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write update server config: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("sync update server config: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close update server config: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return fmt.Errorf("replace update server config: %w", err)
|
||||
}
|
||||
|
||||
directory, err := os.Open(dir)
|
||||
if err == nil {
|
||||
_ = directory.Sync()
|
||||
_ = directory.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,28 +1,46 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Latest struct {
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Sha512 string `json:"sha512"`
|
||||
Size uint `json:"size"`
|
||||
Url string `json:"url"`
|
||||
ManifestVersion int `json:"manifest_version,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Sha512 string `json:"sha512"`
|
||||
LegacySize uint64 `json:"size"`
|
||||
SizeBytes uint64 `json:"size_bytes,omitempty"`
|
||||
UnpackedSizeBytes uint64 `json:"unpacked_size_bytes,omitempty"`
|
||||
Url string `json:"-"`
|
||||
}
|
||||
|
||||
const (
|
||||
maxLatestJSONSize = 64 * 1024
|
||||
)
|
||||
|
||||
var (
|
||||
latestClient = utils.NewUpdateHTTPClient(15 * time.Second)
|
||||
packageNamePattern = regexp.MustCompile(`^nanokvm_[0-9]+\.[0-9]+\.[0-9]+\.tar\.gz$`)
|
||||
versionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`)
|
||||
)
|
||||
|
||||
func (s *Service) GetVersion(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
@@ -39,9 +57,12 @@ func (s *Service) GetVersion(c *gin.Context) {
|
||||
// latest version
|
||||
latestVersion := ""
|
||||
latest, err := getLatest()
|
||||
if err == nil && latest != nil {
|
||||
latestVersion = latest.Version
|
||||
if err != nil {
|
||||
log.Errorf("failed to get latest version: %s", err)
|
||||
rsp.ErrRsp(c, -1, "failed to query latest version")
|
||||
return
|
||||
}
|
||||
latestVersion = latest.Version
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetVersionRsp{
|
||||
Current: currentVersion,
|
||||
@@ -50,23 +71,37 @@ func (s *Service) GetVersion(c *gin.Context) {
|
||||
}
|
||||
|
||||
func getLatest() (*Latest, error) {
|
||||
baseURL := StableURL
|
||||
if isPreviewEnabled() {
|
||||
baseURL = PreviewURL
|
||||
baseURL, err := resolveUpdateBaseURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/latest.json?now=%d", baseURL, time.Now().Unix())
|
||||
|
||||
resp, err := http.Get(url)
|
||||
manifestURL, err := joinUpdateURL(baseURL, "latest.json")
|
||||
if err != nil {
|
||||
log.Debugf("failed to request version: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
parsedManifestURL, err := url.Parse(manifestURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := parsedManifestURL.Query()
|
||||
query.Set("now", fmt.Sprintf("%d", time.Now().Unix()))
|
||||
parsedManifestURL.RawQuery = query.Encode()
|
||||
|
||||
request, err := utils.NewAuthenticatedRequest("GET", parsedManifestURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := latestClient.Do(request)
|
||||
if err != nil {
|
||||
log.Debugf("failed to request version from %s", parsedManifestURL.Redacted())
|
||||
return nil, errors.New("update server is inaccessible")
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxLatestJSONSize+1))
|
||||
if err != nil {
|
||||
log.Errorf("failed to read response: %v", err)
|
||||
return nil, err
|
||||
@@ -76,15 +111,86 @@ func getLatest() (*Latest, error) {
|
||||
log.Errorf("server responded with status code: %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("status code %d", resp.StatusCode)
|
||||
}
|
||||
if len(body) > maxLatestJSONSize {
|
||||
return nil, fmt.Errorf("latest manifest exceeds %d bytes", maxLatestJSONSize)
|
||||
}
|
||||
|
||||
var latest Latest
|
||||
if err := json.Unmarshal(body, &latest); err != nil {
|
||||
log.Errorf("failed to unmarshal response: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
if err := validateLatest(&latest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
latest.Url = fmt.Sprintf("%s/%s", baseURL, latest.Name)
|
||||
latest.Url, err = joinUpdateURL(baseURL, latest.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debugf("get application latest version: %s", latest.Version)
|
||||
return &latest, nil
|
||||
}
|
||||
|
||||
func joinUpdateURL(baseURL string, element string) (string, error) {
|
||||
joined, err := url.JoinPath(baseURL, element)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("join update URL: %w", err)
|
||||
}
|
||||
return joined, nil
|
||||
}
|
||||
|
||||
func validateLatest(latest *Latest) error {
|
||||
if !versionPattern.MatchString(latest.Version) {
|
||||
return errors.New("invalid latest version")
|
||||
}
|
||||
if !packageNamePattern.MatchString(latest.Name) {
|
||||
return errors.New("invalid update package name")
|
||||
}
|
||||
digest, err := base64.StdEncoding.DecodeString(latest.Sha512)
|
||||
if err != nil || len(digest) != 64 {
|
||||
return errors.New("invalid update package sha512")
|
||||
}
|
||||
if latest.LegacySize == 0 {
|
||||
return errors.New("invalid update package size")
|
||||
}
|
||||
switch latest.ManifestVersion {
|
||||
case 0, 1:
|
||||
return nil
|
||||
case 2:
|
||||
if latest.SizeBytes == 0 || latest.SizeBytes > maxPackageSize {
|
||||
return errors.New("invalid update package size_bytes")
|
||||
}
|
||||
if latest.UnpackedSizeBytes == 0 || latest.UnpackedSizeBytes > maxExpandedSize {
|
||||
return errors.New("invalid update package unpacked_size_bytes")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return errors.New("unsupported update manifest version")
|
||||
}
|
||||
}
|
||||
|
||||
func preflightManifestSpace(path string, latest *Latest) error {
|
||||
if latest.ManifestVersion != 2 {
|
||||
return nil
|
||||
}
|
||||
return ensureFreeSpace(path, latest.SizeBytes)
|
||||
}
|
||||
|
||||
func validateDownloadedSize(latest *Latest, written uint64) error {
|
||||
if written > maxPackageSize {
|
||||
return fmt.Errorf("update package exceeds %d bytes", maxPackageSize)
|
||||
}
|
||||
if latest.ManifestVersion == 2 && written != latest.SizeBytes {
|
||||
return fmt.Errorf("update package size mismatch: expected %d bytes, got %d", latest.SizeBytes, written)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateExpandedSize(latest *Latest, expanded uint64) error {
|
||||
if latest.ManifestVersion == 2 && expanded != latest.UnpackedSizeBytes {
|
||||
return fmt.Errorf("update package expanded size mismatch: expected %d bytes, got %d", latest.UnpackedSizeBytes, expanded)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
46
server/service/application/version_test.go
Normal file
46
server/service/application/version_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validLatest() Latest {
|
||||
digest := sha512.Sum512([]byte("package"))
|
||||
return Latest{
|
||||
Version: "1.2.3", Name: "nanokvm_1.2.3.tar.gz",
|
||||
Sha512: base64.StdEncoding.EncodeToString(digest[:]), LegacySize: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLatestV1DoesNotInterpretLegacySizeAsBytes(t *testing.T) {
|
||||
latest := validLatest()
|
||||
latest.LegacySize = 15048 // historic stable manifests used a non-byte value
|
||||
if err := validateLatest(&latest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateDownloadedSize(&latest, 15406125); err != nil {
|
||||
t.Fatalf("v1 must not require equality with legacy size: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLatestV2RequiresExactByteFields(t *testing.T) {
|
||||
latest := validLatest()
|
||||
latest.ManifestVersion = 2
|
||||
latest.SizeBytes = 100
|
||||
latest.UnpackedSizeBytes = 200
|
||||
if err := validateLatest(&latest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateDownloadedSize(&latest, 99); err == nil {
|
||||
t.Fatal("v2 size mismatch was accepted")
|
||||
}
|
||||
if err := validateExpandedSize(&latest, 199); err == nil {
|
||||
t.Fatal("v2 expanded size mismatch was accepted")
|
||||
}
|
||||
latest.ManifestVersion = 3
|
||||
if err := validateLatest(&latest); err == nil {
|
||||
t.Fatal("unknown manifest version was accepted")
|
||||
}
|
||||
}
|
||||
69
server/service/application/workspace.go
Normal file
69
server/service/application/workspace.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type updateWorkspace struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
func newUpdateWorkspace(baseDir string) (*updateWorkspace, error) {
|
||||
if err := os.MkdirAll(baseDir, cacheDirMode); err != nil {
|
||||
return nil, fmt.Errorf("create update cache: %w", err)
|
||||
}
|
||||
if err := os.Chmod(baseDir, cacheDirMode); err != nil {
|
||||
return nil, fmt.Errorf("chmod update cache: %w", err)
|
||||
}
|
||||
dir, err := os.MkdirTemp(baseDir, updateWorkspacePrefix)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create update workspace: %w", err)
|
||||
}
|
||||
return &updateWorkspace{dir: dir}, nil
|
||||
}
|
||||
|
||||
func (w *updateWorkspace) Close() error {
|
||||
return os.RemoveAll(w.dir)
|
||||
}
|
||||
|
||||
func cleanupStaleWorkspaces(baseDir string) error {
|
||||
entries, err := os.ReadDir(baseDir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !strings.HasPrefix(entry.Name(), updateWorkspacePrefix) {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(baseDir, entry.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareCacheForUpdate() error {
|
||||
info, err := os.Stat(AppDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat current application: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("current application is not a directory")
|
||||
}
|
||||
if err := os.MkdirAll(CacheDir, cacheDirMode); err != nil {
|
||||
return fmt.Errorf("create update cache: %w", err)
|
||||
}
|
||||
if err := os.Chmod(CacheDir, cacheDirMode); err != nil {
|
||||
return fmt.Errorf("chmod update cache: %w", err)
|
||||
}
|
||||
if err := cleanupStaleWorkspaces(CacheDir); err != nil {
|
||||
return fmt.Errorf("clean stale update workspace: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
49
server/service/application/workspace_test.go
Normal file
49
server/service/application/workspace_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWorkspaceCleanupIsScopedToUpdaterDirectories(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
for _, name := range []string{"nanokvm-update-old", "update-other", "firmware.tar.xz", "nanokvm-go_1.0.0"} {
|
||||
if err := os.Mkdir(filepath.Join(base, name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := cleanupStaleWorkspaces(base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, "nanokvm-update-old")); !os.IsNotExist(err) {
|
||||
t.Fatalf("stale workspace still exists: %v", err)
|
||||
}
|
||||
for _, name := range []string{"update-other", "firmware.tar.xz", "nanokvm-go_1.0.0"} {
|
||||
if _, err := os.Stat(filepath.Join(base, name)); err != nil {
|
||||
t.Fatalf("unrelated cache entry %s was removed: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWorkspaceIsPrivateAndCloseOnlyRemovesItself(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
keep := filepath.Join(base, "keep")
|
||||
if err := os.WriteFile(keep, []byte("keep"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace, err := newUpdateWorkspace(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.Dir(workspace.dir) != base || !strings.HasPrefix(filepath.Base(workspace.dir), updateWorkspacePrefix) {
|
||||
t.Fatalf("unexpected workspace path %q", workspace.dir)
|
||||
}
|
||||
if err := workspace.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(keep); err != nil {
|
||||
t.Fatalf("unrelated cache entry was removed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/utils"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const AccountFile = "/etc/kvm/pwd"
|
||||
|
||||
type Account struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"` // should be named HashedPassword for clarity
|
||||
}
|
||||
|
||||
func GetAccount() (*Account, error) {
|
||||
if _, err := os.Stat(AccountFile); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return getDefaultAccount(), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(AccountFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var account Account
|
||||
if err = json.Unmarshal(content, &account); err != nil {
|
||||
log.Errorf("unmarshal account failed: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func SetAccount(username string, hashedPassword string) error {
|
||||
account, err := json.Marshal(&Account{
|
||||
Username: username,
|
||||
Password: hashedPassword,
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("failed to marshal account information to json: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = os.MkdirAll(filepath.Dir(AccountFile), 0o644)
|
||||
if err != nil {
|
||||
log.Errorf("create directory %s failed: %s", AccountFile, err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = os.WriteFile(AccountFile, account, 0o644)
|
||||
if err != nil {
|
||||
log.Errorf("write password failed: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CompareAccount(username string, plainPassword string) bool {
|
||||
account, err := GetAccount()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if username != account.Username {
|
||||
return false
|
||||
}
|
||||
|
||||
hashedPassword, err := utils.DecodeDecrypt(plainPassword)
|
||||
if err != nil || hashedPassword == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(hashedPassword))
|
||||
if err != nil {
|
||||
// Compatible with old versions
|
||||
accountHashedPassword, _ := utils.DecodeDecrypt(account.Password)
|
||||
if accountHashedPassword == hashedPassword {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func DelAccount() error {
|
||||
if err := os.Remove(AccountFile); err != nil {
|
||||
log.Errorf("failed to delete password: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getDefaultAccount() *Account {
|
||||
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
|
||||
|
||||
return &Account{
|
||||
Username: "admin",
|
||||
Password: string(hashedPassword),
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/config"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -15,12 +19,9 @@ func (s *Service) Login(c *gin.Context) {
|
||||
var req proto.LoginReq
|
||||
var rsp proto.Response
|
||||
|
||||
// authentication disabled
|
||||
conf := config.GetInstance()
|
||||
if conf.Authentication == "disable" {
|
||||
rsp.OkRspWithData(c, &proto.LoginRsp{
|
||||
Token: "disabled",
|
||||
})
|
||||
rsp.OkRsp(c)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -30,63 +31,99 @@ func (s *Service) Login(c *gin.Context) {
|
||||
rsp.ErrRsp(c, code, msg)
|
||||
return
|
||||
}
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
time.Sleep(3 * time.Second)
|
||||
rsp.ErrRsp(c, -1, "invalid parameters")
|
||||
return
|
||||
}
|
||||
|
||||
if ok := CompareAccount(req.Username, req.Password); !ok {
|
||||
password, err := utils.DecodeDecrypt(req.Password)
|
||||
if err != nil || password == "" {
|
||||
s.loginFailed(c, clientIP, &rsp)
|
||||
return
|
||||
}
|
||||
user, ok, err := authn.DefaultStore.Authenticate(req.Username, password)
|
||||
if err != nil {
|
||||
log.Errorf("load account during login: %s", err)
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
if locked, code, msg := RecordLoginFailure(clientIP); locked {
|
||||
rsp.ErrRsp(c, code, msg)
|
||||
return
|
||||
}
|
||||
|
||||
rsp.ErrRsp(c, -2, "invalid username or password")
|
||||
rsp.ErrRsp(c, -3, "authentication unavailable")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
s.loginFailed(c, clientIP, &rsp)
|
||||
return
|
||||
}
|
||||
|
||||
ClearLoginAttempt(clientIP)
|
||||
|
||||
token, err := middleware.GenerateJWT(req.Username)
|
||||
token, err := middleware.GenerateJWT(user.Username, user.TokenVersion)
|
||||
if err != nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
time.Sleep(time.Second)
|
||||
rsp.ErrRsp(c, -3, "generate token failed")
|
||||
return
|
||||
}
|
||||
setSessionCookie(c, token)
|
||||
rsp.OkRsp(c)
|
||||
log.Infof("user logged in: %s", user.Username)
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.LoginRsp{
|
||||
Token: token,
|
||||
})
|
||||
|
||||
log.Debugf("login success, username: %s", req.Username)
|
||||
func (s *Service) loginFailed(c *gin.Context, clientIP string, rsp *proto.Response) {
|
||||
time.Sleep(2 * time.Second)
|
||||
if locked, code, msg := RecordLoginFailure(clientIP); locked {
|
||||
rsp.ErrRsp(c, code, msg)
|
||||
return
|
||||
}
|
||||
rsp.ErrRsp(c, -2, "invalid username or password")
|
||||
}
|
||||
|
||||
func (s *Service) Logout(c *gin.Context) {
|
||||
conf := config.GetInstance()
|
||||
|
||||
if conf.JWT.RevokeTokensOnLogout {
|
||||
config.RegenerateSecretKey()
|
||||
}
|
||||
|
||||
var rsp proto.Response
|
||||
principal, ok := middleware.CurrentPrincipal(c)
|
||||
conf := config.GetInstance()
|
||||
if ok && conf.Authentication != "disable" && conf.JWT.RevokeTokensOnLogout {
|
||||
if _, err := authn.DefaultStore.Revoke(principal.Username); err != nil {
|
||||
rsp.ErrRsp(c, -1, "failed to revoke session")
|
||||
return
|
||||
}
|
||||
middleware.RevokeUserSessions(principal.Username)
|
||||
log.Infof("user logged out: %s", principal.Username)
|
||||
}
|
||||
clearSessionCookie(c)
|
||||
rsp.OkRsp(c)
|
||||
}
|
||||
|
||||
func (s *Service) GetAccount(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
account, err := GetAccount()
|
||||
if err != nil {
|
||||
principal, ok := middleware.CurrentPrincipal(c)
|
||||
if !ok {
|
||||
rsp.ErrRsp(c, -1, "get account failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetAccountRsp{
|
||||
Username: account.Username,
|
||||
Username: principal.Username,
|
||||
Role: string(principal.Role),
|
||||
Permissions: permissionStrings(principal.Permissions),
|
||||
})
|
||||
log.Debugf("get account successful")
|
||||
}
|
||||
|
||||
func setSessionCookie(c *gin.Context, token string) {
|
||||
conf := config.GetInstance()
|
||||
secure := conf.Proto == "https" || c.Request.TLS != nil ||
|
||||
strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
||||
c.SetSameSite(http.SameSiteStrictMode)
|
||||
c.SetCookie(
|
||||
middleware.CookieName,
|
||||
token,
|
||||
int(conf.JWT.RefreshTokenDuration),
|
||||
"/",
|
||||
"",
|
||||
secure,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
func clearSessionCookie(c *gin.Context) {
|
||||
conf := config.GetInstance()
|
||||
secure := conf.Proto == "https" || c.Request.TLS != nil ||
|
||||
strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
||||
c.SetSameSite(http.SameSiteStrictMode)
|
||||
c.SetCookie(middleware.CookieName, "", -1, "/", "", secure, true)
|
||||
}
|
||||
|
||||
@@ -1,124 +1,126 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/utils"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/config"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var systemPasswordUpdater = changeRootPassword
|
||||
|
||||
func (s *Service) ChangePassword(c *gin.Context) {
|
||||
var req proto.ChangePasswordReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid parameters")
|
||||
return
|
||||
}
|
||||
|
||||
password, err := utils.DecodeDecrypt(req.Password)
|
||||
if err != nil || password == "" {
|
||||
rsp.ErrRsp(c, -2, "invalid password")
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -3, "failed to hash password")
|
||||
return
|
||||
}
|
||||
|
||||
if err = SetAccount(req.Username, string(hashedPassword)); err != nil {
|
||||
rsp.ErrRsp(c, -4, "failed to save password")
|
||||
return
|
||||
}
|
||||
|
||||
// change root password
|
||||
err = changeRootPassword(password)
|
||||
if err != nil {
|
||||
_ = DelAccount()
|
||||
rsp.ErrRsp(c, -5, "failed to change password")
|
||||
principal, ok := middleware.CurrentPrincipal(c)
|
||||
if !ok {
|
||||
rsp.ErrRsp(c, -2, "invalid session")
|
||||
return
|
||||
}
|
||||
currentPassword, err := utils.DecodeDecrypt(req.CurrentPassword)
|
||||
if err != nil || currentPassword == "" {
|
||||
rsp.ErrRsp(c, -3, "current password is required")
|
||||
return
|
||||
}
|
||||
if _, authenticated, authErr := authn.DefaultStore.Authenticate(principal.Username, currentPassword); authErr != nil {
|
||||
rsp.ErrRsp(c, -4, "authentication unavailable")
|
||||
return
|
||||
} else if !authenticated {
|
||||
rsp.ErrRsp(c, -3, "current password is incorrect")
|
||||
return
|
||||
}
|
||||
if err := changeUserPassword(principal.Username, req.Password); err != nil {
|
||||
rsp.ErrRsp(c, -5, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
middleware.RevokeUserSessions(principal.Username)
|
||||
clearSessionCookie(c)
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("change password success, username: %s", req.Username)
|
||||
log.Infof("password changed for user: %s", principal.Username)
|
||||
}
|
||||
|
||||
func (s *Service) IsPasswordUpdated(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
if config.GetInstance().Authentication == "disable" {
|
||||
rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{IsUpdated: true})
|
||||
return
|
||||
}
|
||||
principal, ok := middleware.CurrentPrincipal(c)
|
||||
if !ok {
|
||||
rsp.ErrRsp(c, -1, "invalid session")
|
||||
return
|
||||
}
|
||||
user, err := authn.DefaultStore.Get(principal.Username)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, "failed to get password state")
|
||||
return
|
||||
}
|
||||
rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{IsUpdated: !user.MustChangePassword})
|
||||
}
|
||||
|
||||
if _, err := os.Stat(AccountFile); err != nil {
|
||||
rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{
|
||||
IsUpdated: false,
|
||||
func changeUserPassword(username, encryptedPassword string) error {
|
||||
password, err := utils.DecodeDecrypt(encryptedPassword)
|
||||
if err != nil || password == "" {
|
||||
return errInvalidPassword
|
||||
}
|
||||
if err = authn.ValidatePassword(password); err != nil {
|
||||
return err
|
||||
}
|
||||
user, err := authn.DefaultStore.Get(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user.SystemAccount && user.Role == authn.RoleAdmin && user.Enabled {
|
||||
_, err = authn.DefaultStore.SetPasswordAndRun(username, password, func() error {
|
||||
return systemPasswordUpdater(password)
|
||||
})
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
account, err := GetAccount()
|
||||
if err != nil || account == nil {
|
||||
rsp.ErrRsp(c, -1, "failed to get password")
|
||||
return
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte("admin"))
|
||||
|
||||
rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{
|
||||
// If the hash is not valid, still assume it's not updated
|
||||
// The error we want to see is password and hash not matching
|
||||
IsUpdated: errors.Is(err, bcrypt.ErrMismatchedHashAndPassword),
|
||||
})
|
||||
_, err = authn.DefaultStore.SetPassword(username, password)
|
||||
return err
|
||||
}
|
||||
|
||||
func changeRootPassword(password string) error {
|
||||
err := passwd(password)
|
||||
if err != nil {
|
||||
if err := passwd(password); err != nil {
|
||||
log.Errorf("failed to change root password: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("change root password successful.")
|
||||
log.Debug("change root password successful")
|
||||
return nil
|
||||
}
|
||||
|
||||
func passwd(password string) error {
|
||||
cmd := exec.Command("passwd", "root")
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = stdin.Close()
|
||||
}()
|
||||
|
||||
defer func() { _ = stdin.Close() }()
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err = cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = io.WriteString(stdin, password+"\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if _, err = io.WriteString(stdin, password+"\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = cmd.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return cmd.Wait()
|
||||
}
|
||||
|
||||
307
server/service/auth/service_test.go
Normal file
307
server/service/auth/service_test.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/config"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mervick/aes-everywhere/go/aes256"
|
||||
)
|
||||
|
||||
func TestLoginCookieAndUserAuthorizationLifecycle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
|
||||
restore := useTestStore(store)
|
||||
defer restore()
|
||||
|
||||
service := NewService()
|
||||
router := gin.New()
|
||||
router.POST("/login", service.Login)
|
||||
authenticated := router.Group("/").Use(middleware.CheckToken())
|
||||
authenticated.GET("/account", service.GetAccount)
|
||||
authenticated.POST("/logout", service.Logout)
|
||||
admin := router.Group("/").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
|
||||
admin.GET("/users", service.ListUsers)
|
||||
admin.POST("/users", service.CreateUser)
|
||||
admin.PUT("/users/:username", service.UpdateUser)
|
||||
|
||||
adminCookie := loginCookie(t, router, "admin", "admin")
|
||||
if !adminCookie.HttpOnly || adminCookie.SameSite != http.SameSiteStrictMode || adminCookie.Path != "/" {
|
||||
t.Fatalf("unsafe session cookie: %+v", adminCookie)
|
||||
}
|
||||
|
||||
createBody := map[string]any{
|
||||
"username": "alice",
|
||||
"password": encryptForRequest("valid-password"),
|
||||
"role": "user",
|
||||
}
|
||||
if code := requestJSON(router, http.MethodPost, "/users", createBody, adminCookie); code != http.StatusOK {
|
||||
t.Fatalf("create status = %d", code)
|
||||
}
|
||||
userCookie := loginCookie(t, router, "alice", "valid-password")
|
||||
if code := requestJSON(router, http.MethodGet, "/users", nil, userCookie); code != http.StatusForbidden {
|
||||
t.Fatalf("user management status = %d, want 403", code)
|
||||
}
|
||||
|
||||
disabled := false
|
||||
if code := requestJSON(router, http.MethodPut, "/users/alice", map[string]any{"enabled": disabled}, adminCookie); code != http.StatusOK {
|
||||
t.Fatalf("disable status = %d", code)
|
||||
}
|
||||
if code := requestJSON(router, http.MethodGet, "/account", nil, userCookie); code != http.StatusUnauthorized {
|
||||
t.Fatalf("disabled session status = %d, want 401", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutDoesNotInvalidateAnotherUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
|
||||
restore := useTestStore(store)
|
||||
defer restore()
|
||||
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
|
||||
t.Fatalf("default login: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if err := store.Create("alice", "alice-password", authn.RoleUser); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Create("bob", "bob-password", authn.RoleUser); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
service := NewService()
|
||||
router := gin.New()
|
||||
router.POST("/login", service.Login)
|
||||
authenticated := router.Group("/").Use(middleware.CheckToken())
|
||||
authenticated.GET("/account", service.GetAccount)
|
||||
authenticated.POST("/logout", service.Logout)
|
||||
|
||||
aliceCookie := loginCookie(t, router, "alice", "alice-password")
|
||||
bobCookie := loginCookie(t, router, "bob", "bob-password")
|
||||
if code := requestJSON(router, http.MethodPost, "/logout", nil, aliceCookie); code != http.StatusOK {
|
||||
t.Fatalf("logout status = %d", code)
|
||||
}
|
||||
if code := requestJSON(router, http.MethodGet, "/account", nil, aliceCookie); code != http.StatusUnauthorized {
|
||||
t.Fatalf("logged out session status = %d", code)
|
||||
}
|
||||
if code := requestJSON(router, http.MethodGet, "/account", nil, bobCookie); code != http.StatusOK {
|
||||
t.Fatalf("other user's session status = %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalUserPasswordNeverChangesSystemPassword(t *testing.T) {
|
||||
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
|
||||
restore := useTestStore(store)
|
||||
defer restore()
|
||||
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
|
||||
t.Fatalf("default login: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if err := store.Create("alice", "alice-password", authn.RoleUser); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
originalUpdater := systemPasswordUpdater
|
||||
updates := 0
|
||||
systemPasswordUpdater = func(string) error {
|
||||
updates++
|
||||
return nil
|
||||
}
|
||||
defer func() { systemPasswordUpdater = originalUpdater }()
|
||||
|
||||
if err := changeUserPassword("alice", encryptForRequest("new-password")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updates != 0 {
|
||||
t.Fatalf("system password updated %d times for a normal user", updates)
|
||||
}
|
||||
if _, ok, err := store.Authenticate("alice", "new-password"); err != nil || !ok {
|
||||
t.Fatalf("new web password login: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfPasswordChangeRequiresCurrentPassword(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
|
||||
restore := useTestStore(store)
|
||||
defer restore()
|
||||
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
|
||||
t.Fatalf("default login: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if err := store.Create("alice", "alice-password", authn.RoleUser); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
service := NewService()
|
||||
router := gin.New()
|
||||
router.POST("/login", service.Login)
|
||||
authenticated := router.Group("/").Use(middleware.CheckToken())
|
||||
authenticated.POST("/password", service.ChangePassword)
|
||||
cookie := loginCookie(t, router, "alice", "alice-password")
|
||||
|
||||
recorder := requestJSONRecorder(router, http.MethodPost, "/password", map[string]any{
|
||||
"currentPassword": encryptForRequest("wrong-password"),
|
||||
"password": encryptForRequest("new-password"),
|
||||
}, cookie)
|
||||
var response proto.Response
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode wrong-current-password response: %v", err)
|
||||
}
|
||||
if response.Code != -3 || response.Msg != "current password is incorrect" {
|
||||
t.Fatalf("wrong-current-password response = %+v", response)
|
||||
}
|
||||
if _, ok, err := store.Authenticate("alice", "new-password"); err != nil || ok {
|
||||
t.Fatalf("password changed without current credential: ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
requestJSON(router, http.MethodPost, "/password", map[string]any{
|
||||
"currentPassword": encryptForRequest("alice-password"),
|
||||
"password": encryptForRequest("new-password"),
|
||||
}, cookie)
|
||||
if _, ok, err := store.Authenticate("alice", "new-password"); err != nil || !ok {
|
||||
t.Fatalf("valid password change failed: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdministratorCannotResetDeviceOwnerPassword(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
|
||||
restore := useTestStore(store)
|
||||
defer restore()
|
||||
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
|
||||
t.Fatalf("default login: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if err := store.Create("second-admin", "second-password", authn.RoleAdmin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
originalUpdater := systemPasswordUpdater
|
||||
updates := 0
|
||||
systemPasswordUpdater = func(string) error {
|
||||
updates++
|
||||
return nil
|
||||
}
|
||||
defer func() { systemPasswordUpdater = originalUpdater }()
|
||||
|
||||
service := NewService()
|
||||
router := gin.New()
|
||||
router.POST("/login", service.Login)
|
||||
admin := router.Group("/").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
|
||||
admin.POST("/users/:username/password", service.ChangeUserPassword)
|
||||
cookie := loginCookie(t, router, "second-admin", "second-password")
|
||||
requestJSON(router, http.MethodPost, "/users/admin/password", map[string]any{
|
||||
"password": encryptForRequest("new-password"),
|
||||
}, cookie)
|
||||
|
||||
if updates != 0 {
|
||||
t.Fatalf("administrator changed the device owner's system password %d times", updates)
|
||||
}
|
||||
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
|
||||
t.Fatalf("device owner password changed: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if _, ok, err := store.Authenticate("admin", "new-password"); err != nil || ok {
|
||||
t.Fatalf("unauthorized password became active: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPasswordFailureRollsBackWebPassword(t *testing.T) {
|
||||
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
|
||||
restore := useTestStore(store)
|
||||
defer restore()
|
||||
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
|
||||
t.Fatalf("default login: ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
originalUpdater := systemPasswordUpdater
|
||||
systemPasswordUpdater = func(string) error { return errors.New("passwd failed") }
|
||||
defer func() { systemPasswordUpdater = originalUpdater }()
|
||||
|
||||
if err := changeUserPassword("admin", encryptForRequest("new-password")); err == nil {
|
||||
t.Fatal("system password failure was ignored")
|
||||
}
|
||||
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
|
||||
t.Fatalf("old web password was not restored: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if _, ok, err := store.Authenticate("admin", "new-password"); err != nil || ok {
|
||||
t.Fatalf("failed password stayed active: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func loginCookie(t *testing.T, handler http.Handler, username, password string) *http.Cookie {
|
||||
t.Helper()
|
||||
body := map[string]any{"username": username, "password": encryptForRequest(password)}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := jsonRequest(http.MethodPost, "/login", body)
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d", recorder.Code)
|
||||
}
|
||||
if bytes.Contains(recorder.Body.Bytes(), []byte("token")) {
|
||||
t.Fatalf("login exposed JWT in response: %s", recorder.Body.String())
|
||||
}
|
||||
for _, cookie := range recorder.Result().Cookies() {
|
||||
if cookie.Name == middleware.CookieName {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
t.Fatal("login did not set session cookie")
|
||||
return nil
|
||||
}
|
||||
|
||||
func requestJSON(handler http.Handler, method, path string, body any, cookie *http.Cookie) int {
|
||||
return requestJSONRecorder(handler, method, path, body, cookie).Code
|
||||
}
|
||||
|
||||
func requestJSONRecorder(handler http.Handler, method, path string, body any, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := jsonRequest(method, path, body)
|
||||
if cookie != nil {
|
||||
request.AddCookie(cookie)
|
||||
}
|
||||
handler.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func jsonRequest(method, path string, body any) *http.Request {
|
||||
var data []byte
|
||||
if body != nil {
|
||||
data, _ = json.Marshal(body)
|
||||
}
|
||||
request := httptest.NewRequest(method, path, bytes.NewReader(data))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
return request
|
||||
}
|
||||
|
||||
func encryptForRequest(password string) string {
|
||||
return url.QueryEscape(aes256.Encrypt(password, utils.SecretKey))
|
||||
}
|
||||
|
||||
func useTestStore(store *authn.Store) func() {
|
||||
originalStore := authn.DefaultStore
|
||||
conf := config.GetInstance()
|
||||
originalAuthentication := conf.Authentication
|
||||
originalSecret := conf.JWT.SecretKey
|
||||
originalDuration := conf.JWT.RefreshTokenDuration
|
||||
originalRevokeOnLogout := conf.JWT.RevokeTokensOnLogout
|
||||
authn.DefaultStore = store
|
||||
conf.Authentication = "enable"
|
||||
conf.JWT.SecretKey = "test-secret"
|
||||
conf.JWT.RefreshTokenDuration = 3600
|
||||
conf.JWT.RevokeTokensOnLogout = true
|
||||
return func() {
|
||||
authn.DefaultStore = originalStore
|
||||
conf.Authentication = originalAuthentication
|
||||
conf.JWT.SecretKey = originalSecret
|
||||
conf.JWT.RefreshTokenDuration = originalDuration
|
||||
conf.JWT.RevokeTokensOnLogout = originalRevokeOnLogout
|
||||
}
|
||||
}
|
||||
307
server/service/auth/users.go
Normal file
307
server/service/auth/users.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
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()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, "failed to load users")
|
||||
return
|
||||
}
|
||||
result := make([]proto.UserInfo, 0, len(users))
|
||||
for _, user := range users {
|
||||
result = append(result, proto.UserInfo{
|
||||
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
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid parameters")
|
||||
return
|
||||
}
|
||||
password, err := decodePassword(req.Password)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, err.Error())
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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 && req.Permissions == nil) {
|
||||
rsp.ErrRsp(c, -1, "invalid parameters")
|
||||
return
|
||||
}
|
||||
principal, _ := middleware.CurrentPrincipal(c)
|
||||
patch := authn.UserPatch{Enabled: req.Enabled}
|
||||
if req.Role != nil {
|
||||
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")
|
||||
// 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
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
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)
|
||||
// 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)
|
||||
}
|
||||
|
||||
func (s *Service) ChangeUserPassword(c *gin.Context) {
|
||||
var req proto.ChangePasswordReq
|
||||
var rsp proto.Response
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid parameters")
|
||||
return
|
||||
}
|
||||
username := c.Param("username")
|
||||
user, err := authn.DefaultStore.Get(username)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, err.Error())
|
||||
return
|
||||
}
|
||||
if user.SystemAccount {
|
||||
rsp.ErrRsp(c, -3, "the device owner must change its own password")
|
||||
return
|
||||
}
|
||||
if err := changeUserPassword(username, req.Password); err != nil {
|
||||
rsp.ErrRsp(c, -4, err.Error())
|
||||
return
|
||||
}
|
||||
middleware.RevokeUserSessions(username)
|
||||
rsp.OkRsp(c)
|
||||
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 == "" {
|
||||
return "", errInvalidPassword
|
||||
}
|
||||
if err = authn.ValidatePassword(password); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
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,9 @@ package download
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/service/storage"
|
||||
"NanoKVM-Server/service/storage/diskfs"
|
||||
"NanoKVM-Server/utils"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
@@ -13,49 +16,110 @@ 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
|
||||
|
||||
const (
|
||||
transferSentinelPath = "/tmp/.download_in_progress"
|
||||
transferSentinelPath = utils.TransferSentinelPath
|
||||
downloadStatusIdle downloadStatus = "idle"
|
||||
downloadStatusInProgress downloadStatus = "in_progress"
|
||||
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
|
||||
|
||||
@@ -107,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
|
||||
}
|
||||
@@ -121,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,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()
|
||||
|
||||
@@ -163,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) {
|
||||
@@ -189,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -203,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())
|
||||
@@ -219,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")
|
||||
@@ -242,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
|
||||
}
|
||||
@@ -260,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
|
||||
}
|
||||
@@ -279,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
|
||||
}
|
||||
|
||||
@@ -303,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) {
|
||||
@@ -347,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")
|
||||
@@ -404,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 {
|
||||
@@ -421,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)
|
||||
}
|
||||
@@ -429,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()
|
||||
@@ -442,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
|
||||
@@ -450,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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/stream/mjpeg"
|
||||
|
||||
@@ -19,9 +20,7 @@ import (
|
||||
var gatewayUpgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
CheckOrigin: middleware.CheckWebSocketOrigin,
|
||||
}
|
||||
|
||||
type relayResult struct {
|
||||
@@ -88,6 +87,8 @@ func (s *Service) ConnectGateway(c *gin.Context) {
|
||||
GetSessionManager().Remove(sessionID)
|
||||
return
|
||||
}
|
||||
stopSessionWatcher := middleware.WatchWebSocket(c.Request.Context(), downstream)
|
||||
defer stopSessionWatcher()
|
||||
|
||||
GetSessionManager().AttachUpstream(sessionID, upstream)
|
||||
GetSessionManager().AttachDownstream(sessionID, downstream)
|
||||
|
||||
234
server/service/storage/cache.go
Normal file
234
server/service/storage/cache.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"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
|
||||
partitionBrowsedPath 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
|
||||
|
||||
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) {
|
||||
continue
|
||||
}
|
||||
|
||||
invalidatePartitionCache()
|
||||
|
||||
// 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.
|
||||
signature := partitionSignature()
|
||||
if signature == lastSignature {
|
||||
continue
|
||||
}
|
||||
lastSignature = signature
|
||||
|
||||
partitionCacheMu.Lock()
|
||||
partitionLastChange = time.Now()
|
||||
partitionCacheMu.Unlock()
|
||||
|
||||
notifyPartitionListeners()
|
||||
}
|
||||
}
|
||||
|
||||
// SetPartitionBrowsePath tells the watcher which directory of the shared
|
||||
// folder the web UI is showing. A change inside a subdirectory does not move
|
||||
// the folder's own signature, so the directory being browsed is watched too.
|
||||
// An empty path stops watching a directory.
|
||||
func SetPartitionBrowsePath(browsePath string) {
|
||||
partitionCacheMu.Lock()
|
||||
partitionBrowsedPath = browsePath
|
||||
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 the 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() string {
|
||||
partitionCacheMu.Lock()
|
||||
browsed := partitionBrowsedPath
|
||||
partitionCacheMu.Unlock()
|
||||
|
||||
signature := directorySignature(mediaDirectory)
|
||||
if browsed == "" || browsed == "/" {
|
||||
return signature
|
||||
}
|
||||
|
||||
dir, err := diskfs.ResolveInside(mediaDirectory, browsed)
|
||||
if err != nil {
|
||||
return signature
|
||||
}
|
||||
|
||||
return signature + directorySignature(dir)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
64
server/service/storage/data.go
Normal file
64
server/service/storage/data.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
// 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, "'", `'\''`) + "'"
|
||||
}
|
||||
130
server/service/storage/diskfs/diskfs_test.go
Normal file
130
server/service/storage/diskfs/diskfs_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package diskfs
|
||||
|
||||
import (
|
||||
"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)
|
||||
}
|
||||
}
|
||||
617
server/service/storage/diskfs/mount.go
Normal file
617
server/service/storage/diskfs/mount.go
Normal file
@@ -0,0 +1,617 @@
|
||||
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)
|
||||
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) 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) 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 already exists", rel)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
453
server/service/storage/files.go
Normal file
453
server/service/storage/files.go
Normal file
@@ -0,0 +1,453 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"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 watcher pushes "the shared files changed" for the folder the UI
|
||||
// has open, which is not necessarily its root, so it is told which one
|
||||
// that is. Listing anything else stops that watch.
|
||||
SetPartitionBrowsePath(dir)
|
||||
invalidatePartitionCache()
|
||||
} else {
|
||||
SetPartitionBrowsePath("")
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
861
server/service/storage/media.go
Normal file
861
server/service/storage/media.go
Normal file
@@ -0,0 +1,861 @@
|
||||
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
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package direct
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/stream"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -17,9 +17,7 @@ var (
|
||||
streamer = newStreamer()
|
||||
upgrader = websocket.Upgrader{
|
||||
WriteBufferSize: 256 * 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
CheckOrigin: middleware.CheckWebSocketOrigin,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -29,6 +27,8 @@ func Connect(c *gin.Context) {
|
||||
log.Errorf("failed to upgrade to websocket: %s", err)
|
||||
return
|
||||
}
|
||||
stopSessionWatcher := middleware.WatchWebSocket(c.Request.Context(), ws)
|
||||
defer stopSessionWatcher()
|
||||
client := newClient(ws)
|
||||
if flowWindow, err := strconv.Atoi(c.Query("flow")); err == nil && flowWindow > 0 {
|
||||
client.queue.enableFlowControl(flowWindow)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package direct
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/common"
|
||||
"NanoKVM-Server/service/stream"
|
||||
"NanoKVM-Server/service/vm"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -87,17 +85,14 @@ func (s *Streamer) getClients() []*client {
|
||||
}
|
||||
|
||||
func (s *Streamer) run() {
|
||||
screen := common.GetScreen()
|
||||
common.CheckScreen()
|
||||
fps := screen.FPS
|
||||
subscription := stream.SubscribeH264()
|
||||
defer subscription.Close()
|
||||
|
||||
ticker := time.NewTicker(time.Second / time.Duration(fps))
|
||||
defer ticker.Stop()
|
||||
|
||||
vision := common.GetKvmVision()
|
||||
startTime := time.Now()
|
||||
|
||||
for range ticker.C {
|
||||
for {
|
||||
frame, ok := subscription.Next()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
clients := s.getClients()
|
||||
if len(clients) == 0 {
|
||||
if s.stopIfIdle() {
|
||||
@@ -107,28 +102,19 @@ func (s *Streamer) run() {
|
||||
continue
|
||||
}
|
||||
|
||||
if screen.FPS != fps && screen.FPS != 0 {
|
||||
fps = screen.FPS
|
||||
ticker.Reset(time.Second / time.Duration(fps))
|
||||
}
|
||||
|
||||
if !hasCaptureDemand(clients) {
|
||||
continue
|
||||
}
|
||||
|
||||
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
|
||||
stream.UpdateCaptureStatus(stream.CaptureModeDirect, result)
|
||||
if result < 0 || len(data) == 0 {
|
||||
stream.UpdateCaptureStatus(stream.CaptureModeDirect, frame.Result)
|
||||
if frame.Result < 0 || len(frame.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
timestamp := time.Since(startTime).Microseconds()
|
||||
frame := newOutboundFrame(result == 3, timestamp, data)
|
||||
outbound := newOutboundFrame(frame.Result == 3, frame.Timestamp, frame.Data)
|
||||
for _, client := range clients {
|
||||
client.offer(frame)
|
||||
client.offer(outbound)
|
||||
}
|
||||
|
||||
stream.GetFrameRateCounter().Update()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
185
server/service/stream/h264_source.go
Normal file
185
server/service/stream/h264_source.go
Normal file
@@ -0,0 +1,185 @@
|
||||
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
|
||||
}
|
||||
|
||||
type H264Source struct {
|
||||
mutex sync.Mutex
|
||||
subscribers map[*H264Subscription]struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
var defaultH264Source = &H264Source{
|
||||
subscribers: make(map[*H264Subscription]struct{}),
|
||||
}
|
||||
|
||||
func SubscribeH264() *H264Subscription {
|
||||
return defaultH264Source.subscribe()
|
||||
}
|
||||
|
||||
func (s *H264Source) subscribe() *H264Subscription {
|
||||
subscription := &H264Subscription{
|
||||
frames: make(chan H264Frame, 4),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
s.subscribers[subscription] = struct{}{}
|
||||
start := !s.running
|
||||
if start {
|
||||
s.running = true
|
||||
}
|
||||
s.mutex.Unlock()
|
||||
|
||||
if start {
|
||||
go s.run()
|
||||
}
|
||||
|
||||
return subscription
|
||||
}
|
||||
|
||||
func (s *H264Subscription) Next() (H264Frame, bool) {
|
||||
select {
|
||||
case frame := <-s.frames:
|
||||
return frame, true
|
||||
case <-s.done:
|
||||
return H264Frame{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *H264Subscription) Close() {
|
||||
s.once.Do(func() {
|
||||
s.closed.Store(true)
|
||||
close(s.done)
|
||||
defaultH264Source.remove(s)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *H264Source) remove(subscription *H264Subscription) {
|
||||
s.mutex.Lock()
|
||||
delete(s.subscribers, subscription)
|
||||
s.mutex.Unlock()
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
for range ticker.C {
|
||||
subscribers := s.snapshot()
|
||||
if len(subscribers) == 0 {
|
||||
s.mutex.Lock()
|
||||
if len(s.subscribers) == 0 {
|
||||
s.running = false
|
||||
s.mutex.Unlock()
|
||||
return
|
||||
}
|
||||
s.mutex.Unlock()
|
||||
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
|
||||
}
|
||||
|
||||
frame := H264Frame{
|
||||
Data: data,
|
||||
Result: result,
|
||||
Duration: time.Second / time.Duration(fps),
|
||||
Timestamp: time.Since(startTime).Microseconds(),
|
||||
}
|
||||
for _, subscription := range subscribers {
|
||||
subscription.send(frame)
|
||||
}
|
||||
GetFrameRateCounter().Update()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *H264Source) snapshot() []*H264Subscription {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
subscribers := make([]*H264Subscription, 0, len(s.subscribers))
|
||||
for subscription := range s.subscribers {
|
||||
subscribers = append(subscribers, subscription)
|
||||
}
|
||||
return subscribers
|
||||
}
|
||||
|
||||
func (s *H264Subscription) send(frame H264Frame) bool {
|
||||
if s.closed.Load() {
|
||||
return false
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,16 @@ package webrtc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/rtp/codecs"
|
||||
"github.com/pion/webrtc/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"sync"
|
||||
)
|
||||
|
||||
const h264SDPFmtpLine = "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f"
|
||||
|
||||
func NewClient(ws *websocket.Conn, videoConn *webrtc.PeerConnection) *Client {
|
||||
return &Client{
|
||||
ws: ws,
|
||||
@@ -72,7 +71,11 @@ func (c *Client) ReadMessage() (*Message, error) {
|
||||
func (c *Client) AddTrack() error {
|
||||
// video track
|
||||
videoTrack, err := webrtc.NewTrackLocalStaticRTP(
|
||||
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
|
||||
webrtc.RTPCodecCapability{
|
||||
MimeType: webrtc.MimeTypeH264,
|
||||
ClockRate: 90000,
|
||||
SDPFmtpLine: h264SDPFmtpLine,
|
||||
},
|
||||
"video",
|
||||
"pion-video",
|
||||
)
|
||||
@@ -81,20 +84,6 @@ func (c *Client) AddTrack() error {
|
||||
return err
|
||||
}
|
||||
|
||||
videoPacketizer := rtp.NewPacketizer(
|
||||
1200,
|
||||
100,
|
||||
0x1234ABCD,
|
||||
&codecs.H264Payloader{},
|
||||
rtp.NewRandomSequencer(),
|
||||
90000,
|
||||
)
|
||||
if videoPacketizer == nil {
|
||||
err := errors.New("failed to create rtp packetizer")
|
||||
log.Error(err)
|
||||
return err
|
||||
}
|
||||
|
||||
videoSender, err := c.video.AddTrack(videoTrack)
|
||||
if err != nil {
|
||||
log.Errorf("failed to add video track: %s", err)
|
||||
@@ -103,10 +92,8 @@ func (c *Client) AddTrack() error {
|
||||
go startRTCPReader(videoSender)
|
||||
|
||||
track := &Track{
|
||||
videoPacketizer: videoPacketizer,
|
||||
video: videoTrack,
|
||||
video: videoTrack,
|
||||
}
|
||||
track.updateExtension()
|
||||
|
||||
c.mutex.Lock()
|
||||
c.track = track
|
||||
|
||||
@@ -2,14 +2,15 @@ package webrtc
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/config"
|
||||
"NanoKVM-Server/middleware"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pion/dtls/v3"
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/webrtc/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -17,9 +18,7 @@ import (
|
||||
var (
|
||||
upgrader = websocket.Upgrader{
|
||||
WriteBufferSize: 256 * 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
CheckOrigin: middleware.CheckWebSocketOrigin,
|
||||
}
|
||||
globalManager *WebRTCManager
|
||||
managerOnce sync.Once
|
||||
@@ -39,6 +38,8 @@ func Connect(c *gin.Context) {
|
||||
log.Errorf("failed to create h264 websocket: %s", err)
|
||||
return
|
||||
}
|
||||
stopSessionWatcher := middleware.WatchWebSocket(c.Request.Context(), wsConn)
|
||||
defer stopSessionWatcher()
|
||||
defer func() {
|
||||
_ = wsConn.Close()
|
||||
log.Debugf("h264 websocket disconnected: %s", c.ClientIP())
|
||||
@@ -151,14 +152,6 @@ func createMediaEngine() (*webrtc.MediaEngine, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := mediaEngine.RegisterHeaderExtension(
|
||||
webrtc.RTPHeaderExtensionCapability{URI: "http://www.webrtc.org/experiments/rtp-hdrext/playout-delay"},
|
||||
webrtc.RTPCodecTypeVideo,
|
||||
); err != nil {
|
||||
log.Errorf("failed to register header extension: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return mediaEngine, nil
|
||||
}
|
||||
|
||||
@@ -173,6 +166,14 @@ func createPeerConnection(iceServers []webrtc.ICEServer, mediaEngine *webrtc.Med
|
||||
webrtc.WithSettingEngine(settingEngine),
|
||||
}
|
||||
if mediaEngine != nil {
|
||||
registry := &interceptor.Registry{}
|
||||
if err := webrtc.ConfigureNack(mediaEngine, registry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := webrtc.ConfigureRTCPReports(registry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiOptions = append(apiOptions, webrtc.WithInterceptorRegistry(registry))
|
||||
apiOptions = append(apiOptions, webrtc.WithMediaEngine(mediaEngine))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/common"
|
||||
"NanoKVM-Server/service/stream"
|
||||
"NanoKVM-Server/service/vm"
|
||||
"time"
|
||||
|
||||
"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),
|
||||
clients: make(map[*websocket.Conn]*Client),
|
||||
videoPacketizer: rtp.NewPacketizer(
|
||||
1200,
|
||||
100,
|
||||
0x1234ABCD,
|
||||
&codecs.H264Payloader{},
|
||||
rtp.NewRandomSequencer(),
|
||||
90000,
|
||||
),
|
||||
videoSending: false,
|
||||
}
|
||||
m.updateClientSnapshotLocked()
|
||||
@@ -22,8 +30,6 @@ func NewWebRTCManager() *WebRTCManager {
|
||||
}
|
||||
|
||||
func (m *WebRTCManager) AddClient(ws *websocket.Conn, client *Client) {
|
||||
client.track.updateExtension()
|
||||
|
||||
m.mutex.Lock()
|
||||
m.clients[ws] = client
|
||||
count := m.updateClientSnapshotLocked()
|
||||
@@ -96,52 +102,62 @@ func (m *WebRTCManager) stopVideoStreamIfIdle() bool {
|
||||
}
|
||||
|
||||
func (m *WebRTCManager) sendVideoStream() {
|
||||
screen := common.GetScreen()
|
||||
common.CheckScreen()
|
||||
fps := screen.FPS
|
||||
duration := time.Second / time.Duration(fps)
|
||||
subscription := stream.SubscribeH264()
|
||||
defer subscription.Close()
|
||||
samples, writerDone := m.startVideoWriter()
|
||||
|
||||
vision := common.GetKvmVision()
|
||||
|
||||
ticker := time.NewTicker(duration)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
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
|
||||
}
|
||||
|
||||
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
|
||||
stream.UpdateCaptureStatus(stream.CaptureModeH264, result)
|
||||
if result < 0 || len(data) == 0 {
|
||||
stream.UpdateCaptureStatus(stream.CaptureModeH264, frame.Result)
|
||||
if frame.Result < 0 || len(frame.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
sample := media.Sample{
|
||||
Data: data,
|
||||
Duration: duration,
|
||||
Data: frame.Data,
|
||||
Duration: frame.Duration,
|
||||
}
|
||||
|
||||
for _, client := range clients {
|
||||
if err := client.track.writeVideoSample(sample); err != nil {
|
||||
log.Errorf("failed to write h264 video to client: %s", err)
|
||||
m.RemoveClient(client.WsConn())
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if screen.FPS != fps && screen.FPS != 0 {
|
||||
fps = screen.FPS
|
||||
duration = time.Second / time.Duration(fps)
|
||||
ticker.Reset(duration)
|
||||
}
|
||||
|
||||
stream.GetFrameRateCounter().Update()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return samples, done
|
||||
}
|
||||
|
||||
@@ -157,11 +157,6 @@ func (s *SignalingHandler) handleVideoOffer(data string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.updateHeaderExtensionID(); err != nil {
|
||||
log.Errorf("could not update header extension ID: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
answerByte, err := json.Marshal(answer)
|
||||
if err != nil {
|
||||
log.Errorf("failed to marshal answer: %s", err)
|
||||
@@ -171,30 +166,6 @@ func (s *SignalingHandler) handleVideoOffer(data string) error {
|
||||
return s.client.WriteMessage("video-answer", string(answerByte))
|
||||
}
|
||||
|
||||
// set extension ID
|
||||
func (s *SignalingHandler) updateHeaderExtensionID() error {
|
||||
receivers := s.client.video.GetReceivers()
|
||||
if len(receivers) == 0 {
|
||||
return errors.New("no RTP receiver found for video")
|
||||
}
|
||||
|
||||
params := receivers[0].GetParameters()
|
||||
if len(params.HeaderExtensions) == 0 {
|
||||
return errors.New("no header extensions found in negotiated parameters")
|
||||
}
|
||||
|
||||
for _, ext := range params.HeaderExtensions {
|
||||
if ext.URI == "http://www.webrtc.org/experiments/rtp-hdrext/playout-delay" {
|
||||
s.client.track.playoutDelayExtensionID = uint8(ext.ID)
|
||||
log.Debugf("found and set playout delay extension ID to: %d", ext.ID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Warnf("no track extension found in negotiated parameters, use default value 5")
|
||||
return nil
|
||||
}
|
||||
|
||||
// handle video candidate
|
||||
func (s *SignalingHandler) handleVideoCandidate(data string) error {
|
||||
candidate := webrtc.ICECandidateInit{}
|
||||
|
||||
@@ -2,42 +2,11 @@ package webrtc
|
||||
|
||||
import (
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/webrtc/v4/pkg/media"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func (t *Track) updateExtension() {
|
||||
if t.playoutDelayExtensionID == 0 {
|
||||
t.playoutDelayExtensionID = 5
|
||||
}
|
||||
|
||||
if len(t.playoutDelayExtensionData) == 0 {
|
||||
playoutDelay := &rtp.PlayoutDelayExtension{
|
||||
MinDelay: 0,
|
||||
MaxDelay: 0,
|
||||
}
|
||||
playoutDelayExtensionData, err := playoutDelay.Marshal()
|
||||
if err == nil {
|
||||
t.playoutDelayExtensionData = playoutDelayExtensionData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Track) writeVideoSample(sample media.Sample) error {
|
||||
samples := uint32(sample.Duration.Seconds() * 90000)
|
||||
packets := t.videoPacketizer.Packetize(sample.Data, samples)
|
||||
|
||||
for _, p := range packets {
|
||||
p.Header.Extension = true
|
||||
p.Header.ExtensionProfile = 0xBEDE
|
||||
|
||||
if err := p.Header.SetExtension(t.playoutDelayExtensionID, t.playoutDelayExtensionData); err != nil {
|
||||
log.Errorf("Failed to set extension: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := t.video.WriteRTP(p); err != nil {
|
||||
log.Errorf("failed to write RTP: %v", err)
|
||||
func (t *Track) writeVideoPackets(packets []*rtp.Packet) error {
|
||||
for _, packet := range packets {
|
||||
if err := t.video.WriteRTP(packet); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,12 @@ import (
|
||||
)
|
||||
|
||||
type WebRTCManager struct {
|
||||
clients map[*websocket.Conn]*Client
|
||||
clientSnapshot atomic.Pointer[[]*Client]
|
||||
videoSending bool
|
||||
mutex sync.Mutex
|
||||
viewerVersion uint64
|
||||
clients map[*websocket.Conn]*Client
|
||||
clientSnapshot atomic.Pointer[[]*Client]
|
||||
videoPacketizer rtp.Packetizer
|
||||
videoSending bool
|
||||
mutex sync.Mutex
|
||||
viewerVersion uint64
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
@@ -36,10 +37,7 @@ type SignalingHandler struct {
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
playoutDelayExtensionID uint8
|
||||
playoutDelayExtensionData []byte
|
||||
videoPacketizer rtp.Packetizer
|
||||
video *webrtc.TrackLocalStaticRTP
|
||||
video *webrtc.TrackLocalStaticRTP
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
|
||||
@@ -27,6 +27,7 @@ var (
|
||||
setHDMI = func(enabled bool) { common.GetKvmVision().SetHDMI(enabled) }
|
||||
isHdmiDisabled = utils.IsHdmiDisabled
|
||||
getHdmiIdleTimeout = utils.GetHDMIIdleTimeout
|
||||
getHdmiSignal = func() bool { return common.GetKvmVision().HasHDMISignal() }
|
||||
)
|
||||
|
||||
func (s *Service) ResetHdmi(c *gin.Context) {
|
||||
@@ -63,10 +64,12 @@ func (s *Service) DisableHdmi(c *gin.Context) {
|
||||
|
||||
func (s *Service) GetHdmiState(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
enabled := !isHdmiDisabled()
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetGetHdmiStateRsp{
|
||||
Enabled: !utils.IsHdmiDisabled(),
|
||||
IdleTimeout: utils.GetHDMIIdleTimeout(),
|
||||
Enabled: enabled,
|
||||
Signal: enabled && getHdmiSignal(),
|
||||
IdleTimeout: getHdmiIdleTimeout(),
|
||||
})
|
||||
|
||||
log.Debug("get hdmi state")
|
||||
|
||||
389
server/service/vm/input_region.go
Normal file
389
server/service/vm/input_region.go
Normal file
@@ -0,0 +1,389 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
inputRegionFile = "/etc/kvm/input-region.json"
|
||||
inputRegionMu sync.RWMutex
|
||||
)
|
||||
|
||||
func (s *Service) GetInputResolution(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
width, widthErr := readPositiveInt("/kvmapp/kvm/width")
|
||||
height, heightErr := readPositiveInt("/kvmapp/kvm/height")
|
||||
if widthErr != nil || heightErr != nil {
|
||||
rsp.ErrRsp(c, -1, "failed to read input resolution")
|
||||
return
|
||||
}
|
||||
rsp.OkRspWithData(c, &proto.GetInputResolutionRsp{Width: width, Height: height})
|
||||
}
|
||||
|
||||
func readPositiveInt(path string) (int, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
value, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err != nil || value <= 0 {
|
||||
return 0, errors.New("invalid positive integer")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetInputRegion(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
inputRegionMu.RLock()
|
||||
region, err := readInputRegion(inputRegionFile)
|
||||
inputRegionMu.RUnlock()
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
rsp.OkRspWithData(c, &proto.GetInputRegionRsp{InputRegion: proto.InputRegion{Mode: "off"}})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, "failed to read input region")
|
||||
return
|
||||
}
|
||||
if region == nil {
|
||||
rsp.OkRspWithData(c, &proto.GetInputRegionRsp{InputRegion: proto.InputRegion{Mode: "off"}})
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetInputRegionRsp{InputRegion: *region})
|
||||
}
|
||||
|
||||
func (s *Service) SetInputRegion(c *gin.Context) {
|
||||
var req proto.SetInputRegionReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
inputRegionMu.Lock()
|
||||
defer inputRegionMu.Unlock()
|
||||
|
||||
if req.Mode == "off" || req.Mode == "auto" ||
|
||||
(req.Mode == "manual" && req.FrameWidth == nil && req.FrameHeight == nil &&
|
||||
req.Left == nil && req.Top == nil && req.Width == nil && req.Height == nil) {
|
||||
region, err := readInputRegion(inputRegionFile)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
rsp.ErrRsp(c, -2, "failed to read input region")
|
||||
return
|
||||
}
|
||||
if region == nil {
|
||||
region = &proto.InputRegion{}
|
||||
}
|
||||
region.Mode = req.Mode
|
||||
if req.Resolutions != nil {
|
||||
if err := validateOriginalResolutions(*req.Resolutions); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid original resolutions")
|
||||
return
|
||||
}
|
||||
region.Resolutions = *req.Resolutions
|
||||
}
|
||||
if req.SelectedResolution != nil {
|
||||
region.SelectedResolution = *req.SelectedResolution
|
||||
}
|
||||
if req.Regions != nil {
|
||||
if err := validateManualRegions(*req.Regions); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid manual regions")
|
||||
return
|
||||
}
|
||||
region.Regions = *req.Regions
|
||||
if len(region.Regions) == 0 {
|
||||
clearLegacyManualRegion(region)
|
||||
}
|
||||
}
|
||||
if req.SelectedRegion != nil {
|
||||
region.SelectedRegion = *req.SelectedRegion
|
||||
}
|
||||
if err := validateSelections(*region); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid selected resolution")
|
||||
return
|
||||
}
|
||||
if err := writeInputRegion(inputRegionFile, *region); err != nil {
|
||||
rsp.ErrRsp(c, -2, "failed to save input region mode")
|
||||
return
|
||||
}
|
||||
rsp.OkRsp(c)
|
||||
return
|
||||
}
|
||||
if req.Mode != "manual" {
|
||||
rsp.ErrRsp(c, -1, "invalid input region mode")
|
||||
return
|
||||
}
|
||||
region, err := inputRegionFromRequest(req)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid input region")
|
||||
return
|
||||
}
|
||||
previous, readErr := readInputRegion(inputRegionFile)
|
||||
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
|
||||
rsp.ErrRsp(c, -2, "failed to read input region")
|
||||
return
|
||||
}
|
||||
if previous != nil {
|
||||
region.Resolutions = previous.Resolutions
|
||||
region.SelectedResolution = previous.SelectedResolution
|
||||
region.Regions = previous.Regions
|
||||
region.SelectedRegion = previous.SelectedRegion
|
||||
}
|
||||
if req.Resolutions != nil {
|
||||
if err := validateOriginalResolutions(*req.Resolutions); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid original resolutions")
|
||||
return
|
||||
}
|
||||
region.Resolutions = *req.Resolutions
|
||||
}
|
||||
if req.SelectedResolution != nil {
|
||||
region.SelectedResolution = *req.SelectedResolution
|
||||
}
|
||||
if req.Regions != nil {
|
||||
if err := validateManualRegions(*req.Regions); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid manual regions")
|
||||
return
|
||||
}
|
||||
region.Regions = *req.Regions
|
||||
if len(region.Regions) == 0 {
|
||||
clearLegacyManualRegion(®ion)
|
||||
}
|
||||
}
|
||||
if req.SelectedRegion != nil {
|
||||
region.SelectedRegion = *req.SelectedRegion
|
||||
}
|
||||
if err := validateSelections(region); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid selected resolution")
|
||||
return
|
||||
}
|
||||
if err := writeInputRegion(inputRegionFile, region); err != nil {
|
||||
rsp.ErrRsp(c, -2, "failed to save input region")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
}
|
||||
|
||||
func validateOriginalResolutions(resolutions []proto.OriginalResolution) error {
|
||||
seen := make(map[proto.OriginalResolution]struct{}, len(resolutions))
|
||||
for _, resolution := range resolutions {
|
||||
if resolution.Width <= 0 || resolution.Height <= 0 {
|
||||
return errors.New("resolution dimensions must be positive")
|
||||
}
|
||||
if _, ok := seen[resolution]; ok {
|
||||
return errors.New("duplicate resolution")
|
||||
}
|
||||
seen[resolution] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSelectedResolution(selected string, resolutions []proto.OriginalResolution) error {
|
||||
if selected == "" {
|
||||
return nil
|
||||
}
|
||||
for _, resolution := range resolutions {
|
||||
if selected == resolutionKey(resolution) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("selected resolution not found")
|
||||
}
|
||||
|
||||
func validateManualRegions(regions []proto.ManualRegion) error {
|
||||
seen := make(map[string]struct{}, len(regions))
|
||||
for _, region := range regions {
|
||||
if err := validateManualRegion(region); err != nil {
|
||||
return err
|
||||
}
|
||||
key := manualRegionKey(region)
|
||||
if _, ok := seen[key]; ok {
|
||||
return errors.New("duplicate manual region")
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateManualRegion(region proto.ManualRegion) error {
|
||||
return validateInputRegion(proto.InputRegion{
|
||||
Mode: "manual",
|
||||
FrameWidth: region.FrameWidth,
|
||||
FrameHeight: region.FrameHeight,
|
||||
Left: region.Left,
|
||||
Top: region.Top,
|
||||
Width: region.Width,
|
||||
Height: region.Height,
|
||||
})
|
||||
}
|
||||
|
||||
func validateSelections(region proto.InputRegion) error {
|
||||
if region.SelectedResolution != "" && region.SelectedRegion != "" {
|
||||
return errors.New("only one input region source may be selected")
|
||||
}
|
||||
if err := validateSelectedResolution(region.SelectedResolution, region.Resolutions); err != nil {
|
||||
return err
|
||||
}
|
||||
if region.SelectedRegion == "" {
|
||||
return nil
|
||||
}
|
||||
for _, item := range region.Regions {
|
||||
if region.SelectedRegion == manualRegionKey(item) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("selected manual region not found")
|
||||
}
|
||||
|
||||
func resolutionKey(resolution proto.OriginalResolution) string {
|
||||
return fmt.Sprintf("%dx%d", resolution.Width, resolution.Height)
|
||||
}
|
||||
|
||||
func manualRegionKey(region proto.ManualRegion) string {
|
||||
return fmt.Sprintf("%dx%d", region.Width, region.Height)
|
||||
}
|
||||
|
||||
func clearLegacyManualRegion(region *proto.InputRegion) {
|
||||
region.FrameWidth = 0
|
||||
region.FrameHeight = 0
|
||||
region.Left = 0
|
||||
region.Top = 0
|
||||
region.Width = 0
|
||||
region.Height = 0
|
||||
}
|
||||
|
||||
func inputRegionFromRequest(req proto.SetInputRegionReq) (proto.InputRegion, error) {
|
||||
if req.FrameWidth == nil || req.FrameHeight == nil || req.Left == nil ||
|
||||
req.Top == nil || req.Width == nil || req.Height == nil {
|
||||
return proto.InputRegion{}, errors.New("input region must be complete")
|
||||
}
|
||||
|
||||
region := proto.InputRegion{
|
||||
Mode: "manual",
|
||||
FrameWidth: *req.FrameWidth,
|
||||
FrameHeight: *req.FrameHeight,
|
||||
Left: *req.Left,
|
||||
Top: *req.Top,
|
||||
Width: *req.Width,
|
||||
Height: *req.Height,
|
||||
}
|
||||
return region, validateInputRegion(region)
|
||||
}
|
||||
|
||||
func validateInputRegion(region proto.InputRegion) error {
|
||||
if region.Mode == "off" || region.Mode == "auto" {
|
||||
return nil
|
||||
}
|
||||
if region.Mode == "manual" && region.FrameWidth == 0 && region.FrameHeight == 0 {
|
||||
return nil
|
||||
}
|
||||
if region.Mode != "manual" {
|
||||
return errors.New("invalid input region mode")
|
||||
}
|
||||
if region.FrameWidth <= 0 || region.FrameHeight <= 0 {
|
||||
return errors.New("frame dimensions must be positive")
|
||||
}
|
||||
if region.Left < 0 || region.Top < 0 || region.Width <= 0 || region.Height <= 0 {
|
||||
return errors.New("region dimensions must be positive and offsets non-negative")
|
||||
}
|
||||
if region.Left > region.FrameWidth-region.Width || region.Top > region.FrameHeight-region.Height {
|
||||
return errors.New("region must be inside frame")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readInputRegion(path string) (*proto.InputRegion, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var region *proto.InputRegion
|
||||
if err := json.Unmarshal(data, ®ion); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
if region == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if region.Mode == "" {
|
||||
region.Mode = "manual"
|
||||
}
|
||||
if len(region.Regions) == 0 && region.FrameWidth > 0 && region.FrameHeight > 0 {
|
||||
manual := proto.ManualRegion{
|
||||
FrameWidth: region.FrameWidth,
|
||||
FrameHeight: region.FrameHeight,
|
||||
Left: region.Left,
|
||||
Top: region.Top,
|
||||
Width: region.Width,
|
||||
Height: region.Height,
|
||||
}
|
||||
region.Regions = []proto.ManualRegion{manual}
|
||||
if region.SelectedResolution == "" {
|
||||
region.SelectedRegion = manualRegionKey(manual)
|
||||
}
|
||||
}
|
||||
if err := validateInputRegion(*region); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
if err := validateManualRegions(region.Regions); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
if err := validateSelections(*region); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return region, nil
|
||||
}
|
||||
|
||||
func writeInputRegion(path string, region proto.InputRegion) error {
|
||||
data, err := json.Marshal(region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(dir, ".input-region-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Chmod(0o644); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpPath, path)
|
||||
}
|
||||
|
||||
func removeInputRegion(path string) error {
|
||||
err := os.Remove(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/middleware"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
@@ -26,9 +26,7 @@ type WinSize struct {
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: maxMessageSize,
|
||||
WriteBufferSize: maxMessageSize,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
CheckOrigin: middleware.CheckWebSocketOrigin,
|
||||
}
|
||||
|
||||
func (s *Service) Terminal(c *gin.Context) {
|
||||
@@ -37,11 +35,14 @@ func (s *Service) Terminal(c *gin.Context) {
|
||||
log.Errorf("failed to init websocket: %s", err)
|
||||
return
|
||||
}
|
||||
stopSessionWatcher := middleware.WatchWebSocket(c.Request.Context(), ws)
|
||||
defer stopSessionWatcher()
|
||||
defer func() {
|
||||
_ = ws.Close()
|
||||
}()
|
||||
|
||||
cmd := exec.Command("/bin/sh")
|
||||
cmd := exec.Command("/bin/sh", "-l")
|
||||
cmd.Dir = "/root"
|
||||
ptmx, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
log.Errorf("failed to start pty: %s", err)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user