mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-10 01:49:00 -06:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0ff328db8 | ||
|
|
ff45b46c49 | ||
|
|
bd070b2f5c | ||
|
|
5c0bef019f | ||
|
|
283c1390e5 | ||
|
|
7f95fe9bbd | ||
|
|
a9e1ef2459 | ||
|
|
2ba45a2147 | ||
|
|
9e6d280826 | ||
|
|
527caeff6a | ||
|
|
71ab9127dd | ||
|
|
d382f0621d | ||
|
|
9415afb359 | ||
|
|
e5f6dfabaa | ||
|
|
757e0f61c5 | ||
|
|
761b02ed7e | ||
|
|
562ea8277c | ||
|
|
7abec3cbad | ||
|
|
8fc31553f7 | ||
|
|
9e50040713 | ||
|
|
0f8ef20fb5 | ||
|
|
7cda3b2920 | ||
|
|
40bbeab8ae | ||
|
|
eb20fb41aa | ||
|
|
21e834b4c8 | ||
|
|
8773605715 | ||
|
|
b23eb33611 | ||
|
|
ef432da2fe | ||
|
|
96c86f6098 | ||
|
|
a7d7574e70 | ||
|
|
b587b9f912 | ||
|
|
004fd59093 | ||
|
|
774d314ce9 | ||
|
|
e4d23014ba | ||
|
|
dc050d1f19 | ||
|
|
57e1f6a4d6 | ||
|
|
d578c63036 | ||
|
|
6b98f5415f | ||
|
|
64ce12cc0d | ||
|
|
183f8dc983 | ||
|
|
7eee77a33d | ||
|
|
c1986acf65 | ||
|
|
c999a883bd | ||
|
|
70f8503cbb | ||
|
|
5b0a7d2a91 | ||
|
|
4fee758ff0 | ||
|
|
736259baa3 | ||
|
|
83b600bbee | ||
|
|
6071a6f57c | ||
|
|
69f5da6fc7 | ||
|
|
3b2ba7c0c1 | ||
|
|
208998c13a | ||
|
|
3de4a18eb4 | ||
|
|
863fea9ab8 | ||
|
|
f65c1b06aa | ||
|
|
fe34008ce3 | ||
|
|
961d7f2e89 | ||
|
|
94749ea621 | ||
|
|
6ae742deed | ||
|
|
b653f905e4 | ||
|
|
af19795c29 | ||
|
|
be7584d908 | ||
|
|
a960417739 | ||
|
|
3f0621e7ed | ||
|
|
515c5e2a76 | ||
|
|
cb025f04b4 | ||
|
|
dff794cee4 | ||
|
|
8eca1edf47 |
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
|
||||
132
.github/workflows/builder-image.yml
vendored
Normal file
132
.github/workflows/builder-image.yml
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
name: Builder Image
|
||||
|
||||
# Builds the nanokvm-builder image (riscv64 musl toolchain, Go, and a MaixCDK
|
||||
# checkout patched with support/sg2002/additional) and publishes it to GHCR so
|
||||
# the release workflow does not have to rebuild the SDK on every tag.
|
||||
#
|
||||
# The image is deliberately not rebuilt on every push: it only carries the
|
||||
# toolchain. Release builds re-sync support/sg2002/additional into MaixCDK and
|
||||
# recompile from the checked-out source, so a slightly stale image is harmless.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- docker/**
|
||||
- .github/workflows/builder-image.yml
|
||||
|
||||
concurrency:
|
||||
group: builder-image
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and publish nanokvm-builder
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 240
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
echo "Before:"
|
||||
df -h /
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
|
||||
/usr/local/share/boost /usr/local/share/powershell
|
||||
sudo docker image prune -af
|
||||
echo "After:"
|
||||
df -h /
|
||||
|
||||
- name: Resolve image reference
|
||||
id: image
|
||||
env:
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
run: |
|
||||
# GHCR only accepts lowercase repository paths.
|
||||
owner=$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')
|
||||
echo "ref=ghcr.io/$owner/nanokvm-builder" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Logging in before the build means a credential or registry problem fails
|
||||
# in seconds instead of after a full SDK build.
|
||||
- name: Log in to GHCR
|
||||
env:
|
||||
GHCR_USER: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
ok=0
|
||||
for attempt in 1 2 3; do
|
||||
if printf '%s' "$GHCR_TOKEN" \
|
||||
| docker login ghcr.io -u "$GHCR_USER" --password-stdin; then
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
echo "login attempt $attempt failed, retrying in 15s"
|
||||
sleep 15
|
||||
done
|
||||
if [ "$ok" -ne 1 ]; then
|
||||
echo "::error::could not log in to ghcr.io after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build image
|
||||
run: |
|
||||
# The Dockerfile chowns MaixCDK to DOCKER_UID/DOCKER_GID and the
|
||||
# entrypoint gosu's to the caller's ids, so bake in the ids the
|
||||
# release job will actually run as. Otherwise the SDK tree belongs to
|
||||
# 1000 and syncing components into it fails with permission denied.
|
||||
docker build \
|
||||
--build-arg DOCKER_UID="$(id -u)" \
|
||||
--build-arg DOCKER_GID="$(id -g)" \
|
||||
--tag "${{ steps.image.outputs.ref }}:latest" \
|
||||
--tag "${{ steps.image.outputs.ref }}:${{ github.sha }}" \
|
||||
--file docker/Dockerfile \
|
||||
.
|
||||
|
||||
- name: Verify toolchain
|
||||
run: |
|
||||
docker run --rm -i "${{ steps.image.outputs.ref }}:latest" go version
|
||||
docker run --rm -i "${{ steps.image.outputs.ref }}:latest" \
|
||||
riscv64-unknown-linux-musl-gcc --version | head -1
|
||||
docker run --rm -i "${{ steps.image.outputs.ref }}:latest" patchelf --version
|
||||
|
||||
- name: Push image
|
||||
env:
|
||||
IMAGE_REF: ${{ steps.image.outputs.ref }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
# ghcr.io occasionally times out; a retry is cheaper than rebuilding.
|
||||
# Push the immutable sha tag first so a failure there cannot leave
|
||||
# :latest — which release.yml pulls — already moved.
|
||||
for tag in "$SHA" latest; do
|
||||
ok=0
|
||||
for attempt in 1 2 3; do
|
||||
if docker push "$IMAGE_REF:$tag"; then
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
echo "push of $tag attempt $attempt failed, retrying in 15s"
|
||||
sleep 15
|
||||
done
|
||||
if [ "$ok" -ne 1 ]; then
|
||||
echo "::error::could not push $IMAGE_REF:$tag after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
{
|
||||
echo "### Builder image published"
|
||||
echo
|
||||
echo '```'
|
||||
echo "${{ steps.image.outputs.ref }}:latest"
|
||||
echo "${{ steps.image.outputs.ref }}:${{ github.sha }}"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
108
.github/workflows/create-tag.yml
vendored
Normal file
108
.github/workflows/create-tag.yml
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
name: NanoKVM Create Tag
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Numeric version to tag, e.g. 2.5.0
|
||||
required: true
|
||||
type: string
|
||||
commit_sha:
|
||||
description: Full commit SHA on main (empty uses latest main)
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: create-tag-${{ inputs.version }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
create-tag:
|
||||
name: Create annotated tag
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
|
||||
- name: Validate target
|
||||
id: target
|
||||
env:
|
||||
COMMIT_INPUT: ${{ inputs.commit_sha }}
|
||||
VERSION_INPUT: ${{ inputs.version }}
|
||||
run: |
|
||||
VERSION="$VERSION_INPUT"
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error::invalid version '$VERSION', expected MAJOR.MINOR.PATCH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch --force --tags origin main
|
||||
|
||||
if [ -n "$COMMIT_INPUT" ]; then
|
||||
if ! echo "$COMMIT_INPUT" | grep -qE '^[0-9a-fA-F]{40}$'; then
|
||||
echo "::error::commit_sha must be a full 40-character hexadecimal SHA"
|
||||
exit 1
|
||||
fi
|
||||
TARGET_SHA=$(git rev-parse "${COMMIT_INPUT}^{commit}" 2>/dev/null) || {
|
||||
echo "::error::commit '$COMMIT_INPUT' does not exist"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
TARGET_SHA=$(git rev-parse 'origin/main^{commit}')
|
||||
fi
|
||||
|
||||
if ! git merge-base --is-ancestor "$TARGET_SHA" origin/main; then
|
||||
echo "::error::commit '$TARGET_SHA' is not reachable from origin/main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set +e
|
||||
git ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null
|
||||
TAG_STATUS=$?
|
||||
set -e
|
||||
case "$TAG_STATUS" in
|
||||
0)
|
||||
echo "::error::tag '$VERSION' already exists"
|
||||
exit 1
|
||||
;;
|
||||
2) ;;
|
||||
*)
|
||||
echo "::error::failed to check whether tag '$VERSION' exists"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "sha=$TARGET_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create and push tag
|
||||
env:
|
||||
TARGET_SHA: ${{ steps.target.outputs.sha }}
|
||||
VERSION: ${{ steps.target.outputs.version }}
|
||||
run: |
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git tag -a "$VERSION" "$TARGET_SHA" -m "Release $VERSION"
|
||||
git push origin "refs/tags/$VERSION"
|
||||
./scripts/verify-release-tag.sh "$VERSION" "$TARGET_SHA" >/dev/null
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
TARGET_SHA: ${{ steps.target.outputs.sha }}
|
||||
VERSION: ${{ steps.target.outputs.version }}
|
||||
run: |
|
||||
{
|
||||
echo '### NanoKVM tag created'
|
||||
echo
|
||||
echo "- Tag: \`$VERSION\`"
|
||||
echo "- Commit: \`$TARGET_SHA\`"
|
||||
echo
|
||||
echo 'No package, GitHub Release, or CDN object was created.'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
296
.github/workflows/package.yml
vendored
Normal file
296
.github/workflows/package.yml
vendored
Normal file
@@ -0,0 +1,296 @@
|
||||
name: NanoKVM Package
|
||||
|
||||
# Builds nanokvm_<version>.tar.gz and the latest.json manifest that the
|
||||
# on-device updater consumes (server/service/application/). Pull requests get a
|
||||
# uniquely identified, short-lived test artifact. Release publication is a
|
||||
# separate, explicit workflow that calls this one for an existing tag.
|
||||
#
|
||||
# This workflow does NOT publish to cdn.sipeed.com. Uploading latest.json is what
|
||||
# actually offers the update to every device in the field, so that step stays
|
||||
# manual and deliberate.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/workflows/create-tag.yml
|
||||
- .github/workflows/package.yml
|
||||
- .github/workflows/release.yml
|
||||
- kvmapp/**
|
||||
- scripts/**
|
||||
- server/**
|
||||
- support/**
|
||||
- tools/nanokvm_update_edid/**
|
||||
- web/**
|
||||
- Makefile
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Version to package, e.g. 2.4.4
|
||||
required: true
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: Numeric version to package
|
||||
required: true
|
||||
type: string
|
||||
ref:
|
||||
description: Existing tag or commit to check out
|
||||
required: true
|
||||
type: string
|
||||
outputs:
|
||||
artifact_name:
|
||||
description: Uploaded Actions artifact name
|
||||
value: ${{ jobs.package.outputs.artifact_name }}
|
||||
version:
|
||||
description: Packaged version
|
||||
value: ${{ jobs.package.outputs.version }}
|
||||
|
||||
concurrency:
|
||||
group: package-${{ github.event.pull_request.number || inputs.ref || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: Build package
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
outputs:
|
||||
artifact_name: ${{ steps.version.outputs.artifact_name }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
steps:
|
||||
- name: Checkout event ref
|
||||
if: inputs.ref == ''
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# package.sh derives SOURCE_DATE_EPOCH from the commit date.
|
||||
fetch-depth: 0
|
||||
# Build scripts from a pull request must not inherit checkout's token.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout requested ref
|
||||
if: inputs.ref != ''
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Resolve version
|
||||
id: version
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
BUILD_SHA=$(git rev-parse HEAD)
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
VERSION="0.${PR_NUMBER}.${GITHUB_RUN_NUMBER}"
|
||||
SOURCE_SHA="$HEAD_SHA"
|
||||
elif [ -n "$INPUT_VERSION" ]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
SOURCE_SHA="$BUILD_SHA"
|
||||
else
|
||||
VERSION="$REF_NAME"
|
||||
SOURCE_SHA="$BUILD_SHA"
|
||||
fi
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error::invalid version '$VERSION', expected MAJOR.MINOR.PATCH"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$SOURCE_SHA" ]; then
|
||||
echo "::error::could not resolve source commit"
|
||||
exit 1
|
||||
fi
|
||||
SHORT_SHA=$(printf '%s' "$SOURCE_SHA" | cut -c1-12)
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
ARTIFACT_NAME="nanokvm-pr-${PR_NUMBER}-${SHORT_SHA}-run-${GITHUB_RUN_ID}-attempt-${GITHUB_RUN_ATTEMPT}"
|
||||
else
|
||||
ARTIFACT_NAME="nanokvm-${VERSION}-${SHORT_SHA}-run-${GITHUB_RUN_ID}-attempt-${GITHUB_RUN_ATTEMPT}"
|
||||
fi
|
||||
{
|
||||
echo "artifact_name=$ARTIFACT_NAME"
|
||||
echo "build_sha=$BUILD_SHA"
|
||||
echo "source_sha=$SOURCE_SHA"
|
||||
echo "version=$VERSION"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "Packaging version $VERSION as $ARTIFACT_NAME"
|
||||
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
df -h /
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
|
||||
df -h /
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up pnpm
|
||||
run: npm install --global pnpm@11
|
||||
|
||||
- name: Build frontend
|
||||
run: make web
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
GHCR_USER: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
ok=0
|
||||
for attempt in 1 2 3; do
|
||||
if printf '%s' "$GHCR_TOKEN" \
|
||||
| docker login ghcr.io -u "$GHCR_USER" --password-stdin; then
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
echo "login attempt $attempt failed, retrying in 15s"
|
||||
sleep 15
|
||||
done
|
||||
if [ "$ok" -ne 1 ]; then
|
||||
echo "::error::could not log in to ghcr.io after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Pull builder image
|
||||
id: image
|
||||
env:
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
run: |
|
||||
# GHCR only accepts lowercase repository paths.
|
||||
# shellcheck disable=SC2153 # OWNER is supplied through the step environment.
|
||||
owner=$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')
|
||||
IMAGE_REPO="ghcr.io/$owner/nanokvm-builder"
|
||||
TAGGED_REF="${IMAGE_REPO}:latest"
|
||||
ok=0
|
||||
for attempt in 1 2 3; do
|
||||
if docker pull "$TAGGED_REF"; then
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
echo "pull attempt $attempt failed, retrying in 15s"
|
||||
sleep 15
|
||||
done
|
||||
if [ "$ok" -ne 1 ]; then
|
||||
echo "::error::could not pull $TAGGED_REF - run the 'Builder Image' workflow first and ensure PR builds can pull it without credentials"
|
||||
exit 1
|
||||
fi
|
||||
RESOLVED_REF=$(docker image inspect --format='{{index .RepoDigests 0}}' "$TAGGED_REF")
|
||||
case "$RESOLVED_REF" in
|
||||
"$IMAGE_REPO"@sha256:*) ;;
|
||||
*)
|
||||
echo "::error::could not resolve immutable digest for $TAGGED_REF (got '$RESOLVED_REF')"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "Pulled $RESOLVED_REF"
|
||||
echo "ref=$RESOLVED_REF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build riscv64 artifacts
|
||||
run: |
|
||||
make release-build \
|
||||
DOCKER_TTY= \
|
||||
IMAGE_NAME="${{ steps.image.outputs.ref }}"
|
||||
|
||||
- name: Assemble package
|
||||
run: make package VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Compare against the published release
|
||||
# Informational only: highlights what changed relative to what devices
|
||||
# are currently running. Never blocks the build.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
./scripts/compare-release.sh \
|
||||
"build/release/nanokvm_${{ steps.version.outputs.version }}.tar.gz"
|
||||
|
||||
- name: Write build provenance
|
||||
env:
|
||||
ARTIFACT_NAME: ${{ steps.version.outputs.artifact_name }}
|
||||
BUILDER_IMAGE: ${{ steps.image.outputs.ref }}
|
||||
BUILD_SHA: ${{ steps.version.outputs.build_sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
SOURCE_SHA: ${{ steps.version.outputs.source_sha }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
TARBALL="build/release/nanokvm_${VERSION}.tar.gz"
|
||||
TARBALL_NAME=$(basename "$TARBALL")
|
||||
SHA256_HEX=$(sha256sum "$TARBALL" | cut -d' ' -f1)
|
||||
SHA512_HEX=$(sha512sum "$TARBALL" | cut -d' ' -f1)
|
||||
SHA512_BASE64=$(jq -er '.sha512 | select(type == "string" and length > 0)' \
|
||||
build/release/latest.json)
|
||||
ACTUAL_BASE64=$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)
|
||||
if [ "$SHA512_BASE64" != "$ACTUAL_BASE64" ]; then
|
||||
echo "::error::latest.json sha512 does not match $TARBALL"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s %s\n' "$SHA256_HEX" "$TARBALL_NAME" > build/release/sha256.txt
|
||||
{
|
||||
echo "artifact=${ARTIFACT_NAME}"
|
||||
echo "version=${VERSION}"
|
||||
echo "event=${GITHUB_EVENT_NAME}"
|
||||
echo "pull_request=${PR_NUMBER}"
|
||||
echo "source_sha=${SOURCE_SHA}"
|
||||
echo "build_sha=${BUILD_SHA}"
|
||||
echo "builder_image=${BUILDER_IMAGE}"
|
||||
echo "run_id=${GITHUB_RUN_ID}"
|
||||
echo "run_attempt=${GITHUB_RUN_ATTEMPT}"
|
||||
echo "run_url=https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "tarball=${TARBALL_NAME}"
|
||||
echo "sha256_hex=${SHA256_HEX}"
|
||||
echo "sha512_hex=${SHA512_HEX}"
|
||||
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 }}
|
||||
run: |
|
||||
{
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
echo "> [!WARNING]"
|
||||
echo "> PR TEST ONLY — DO NOT PUBLISH TO CDN"
|
||||
echo
|
||||
fi
|
||||
echo "### nanokvm_${VERSION}.tar.gz"
|
||||
echo
|
||||
echo '```text'
|
||||
cat build/release/BUILD_INFO.txt
|
||||
echo '```'
|
||||
echo
|
||||
echo '```json'
|
||||
cat build/release/latest.json
|
||||
echo '```'
|
||||
echo
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
echo "Upload only the inner tarball through NanoKVM's manual offline update UI."
|
||||
else
|
||||
echo "Publishing to \`cdn.sipeed.com/nanokvm/\` is a separate manual step."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.version.outputs.artifact_name }}
|
||||
path: |
|
||||
build/release/nanokvm_${{ steps.version.outputs.version }}.tar.gz
|
||||
build/release/latest.json
|
||||
build/release/sha256.txt
|
||||
build/release/BUILD_INFO.txt
|
||||
if-no-files-found: error
|
||||
retention-days: ${{ github.event_name == 'pull_request' && 7 || 90 }}
|
||||
244
.github/workflows/release.yml
vendored
Normal file
244
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,244 @@
|
||||
name: NanoKVM Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing numeric tag to release, e.g. 2.5.0
|
||||
required: true
|
||||
type: string
|
||||
release_action:
|
||||
description: Publish a release or promote a tested prerelease
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- publish-prerelease
|
||||
- publish-stable
|
||||
- promote-stable
|
||||
|
||||
# Latest is repository-global, so all Release changes must be serialized.
|
||||
concurrency:
|
||||
group: nanokvm-github-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Resolve release tag
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
source_sha: ${{ steps.tag.outputs.source_sha }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify tag
|
||||
id: tag
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
SOURCE_SHA=$(./scripts/verify-release-tag.sh "$TAG")
|
||||
echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
package:
|
||||
name: Build tag package
|
||||
needs: validate
|
||||
if: inputs.release_action != 'promote-stable'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
uses: ./.github/workflows/package.yml
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.source_sha }}
|
||||
version: ${{ inputs.tag }}
|
||||
|
||||
publish:
|
||||
name: Publish GitHub release
|
||||
needs:
|
||||
- validate
|
||||
- package
|
||||
if: inputs.release_action != 'promote-stable'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout release tooling
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download package artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ needs.package.outputs.artifact_name }}
|
||||
path: build/release
|
||||
|
||||
- name: Publish release
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_ACTION: ${{ inputs.release_action }}
|
||||
SOURCE_SHA: ${{ needs.validate.outputs.source_sha }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
./scripts/verify-release-assets.sh build/release "$TAG"
|
||||
|
||||
case "$RELEASE_ACTION" in
|
||||
publish-prerelease) IS_PRERELEASE=true ;;
|
||||
publish-stable) IS_PRERELEASE=false ;;
|
||||
*)
|
||||
echo "::error::invalid publish action '$RELEASE_ACTION'"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
ENDPOINT="repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}"
|
||||
set +e
|
||||
HEADERS=$(gh api --include --silent "$ENDPOINT" 2>&1)
|
||||
API_STATUS=$?
|
||||
set -e
|
||||
HTTP_STATUS=$(printf '%s\n' "$HEADERS" | awk '
|
||||
$1 ~ /^HTTP\// { status = $2 }
|
||||
END { print status }
|
||||
')
|
||||
if [ "$API_STATUS" -eq 0 ] && [ "$HTTP_STATUS" = 200 ]; then
|
||||
echo "::error::release or draft '$TAG' already exists"
|
||||
echo "Publishing is one-shot. Inspect it at https://github.com/${GITHUB_REPOSITORY}/releases."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$API_STATUS" -eq 0 ] || [ "$HTTP_STATUS" != 404 ]; then
|
||||
echo "::error::failed to query release '$TAG' (HTTP status: ${HTTP_STATUS:-unknown})"
|
||||
printf '%s\n' "$HEADERS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARBALL="build/release/nanokvm_${TAG}.tar.gz"
|
||||
SHA256=$(sha256sum "$TARBALL" | cut -d' ' -f1)
|
||||
SHA512=$(jq -er '.sha512' build/release/latest.json)
|
||||
# Backticks below are intentional Markdown, not command substitution.
|
||||
# shellcheck disable=SC2016
|
||||
NOTES=$(printf '%s\n' \
|
||||
"Application package for NanoKVM ${TAG}." \
|
||||
'' \
|
||||
'### Checksums' \
|
||||
'' \
|
||||
'`SHA-256` (hex):' \
|
||||
'' \
|
||||
" ${SHA256}" \
|
||||
'' \
|
||||
'`SHA-512` (base64, as expected by the on-device updater):' \
|
||||
'' \
|
||||
" ${SHA512}" \
|
||||
'' \
|
||||
'To offer this build over OTA, publish it through the separate CDN workflow.')
|
||||
|
||||
STATE_ARGS=(--prerelease="$IS_PRERELEASE")
|
||||
if [ "$IS_PRERELEASE" = true ]; then
|
||||
STATE_ARGS+=(--latest=false)
|
||||
fi
|
||||
|
||||
./scripts/verify-release-tag.sh "$TAG" "$SOURCE_SHA" >/dev/null
|
||||
gh release create "$TAG" \
|
||||
--verify-tag \
|
||||
--title "nanokvm@${TAG}" \
|
||||
--notes "$NOTES" \
|
||||
"${STATE_ARGS[@]}" \
|
||||
"$TARBALL" build/release/latest.json build/release/sha256.txt
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
RELEASE_ACTION: ${{ inputs.release_action }}
|
||||
SOURCE_SHA: ${{ needs.validate.outputs.source_sha }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
{
|
||||
echo '### NanoKVM GitHub Release published'
|
||||
echo
|
||||
echo "- Tag: \`$TAG\`"
|
||||
echo "- Commit: \`$SOURCE_SHA\`"
|
||||
echo "- Action: \`$RELEASE_ACTION\`"
|
||||
echo
|
||||
echo 'No CDN object was uploaded.'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
promote:
|
||||
name: Promote verified prerelease
|
||||
needs: validate
|
||||
if: inputs.release_action == 'promote-stable'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout release tooling
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify and promote release
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SOURCE_SHA: ${{ needs.validate.outputs.source_sha }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
RELEASE_JSON=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}")
|
||||
if [ "$(jq -r '.draft' <<< "$RELEASE_JSON")" != false ]; then
|
||||
echo "::error::release '$TAG' is a draft"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(jq -r '.prerelease' <<< "$RELEASE_JSON")" != true ]; then
|
||||
echo "::error::release '$TAG' is not a prerelease"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT_ASSETS=$(jq -r '.assets[].name' <<< "$RELEASE_JSON" | LC_ALL=C sort)
|
||||
EXPECTED_ASSETS=$(printf '%s\n' \
|
||||
latest.json "nanokvm_${TAG}.tar.gz" sha256.txt | LC_ALL=C sort)
|
||||
if [ "$CURRENT_ASSETS" != "$EXPECTED_ASSETS" ]; then
|
||||
echo "::error::release '$TAG' does not have the exact expected asset set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p build/release
|
||||
gh release download "$TAG" --dir build/release \
|
||||
--pattern "nanokvm_${TAG}.tar.gz" \
|
||||
--pattern latest.json \
|
||||
--pattern sha256.txt
|
||||
./scripts/verify-release-assets.sh build/release "$TAG"
|
||||
|
||||
./scripts/verify-release-tag.sh "$TAG" "$SOURCE_SHA" >/dev/null
|
||||
gh release edit "$TAG" --prerelease=false --latest
|
||||
./scripts/verify-release-tag.sh "$TAG" "$SOURCE_SHA" >/dev/null
|
||||
|
||||
if [ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" --jq '.prerelease')" != false ]; then
|
||||
echo "::error::release '$TAG' was not promoted"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name')" != "$TAG" ]; then
|
||||
echo "::error::release '$TAG' was not marked latest"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
SOURCE_SHA: ${{ needs.validate.outputs.source_sha }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
{
|
||||
echo '### NanoKVM prerelease promoted'
|
||||
echo
|
||||
echo "- Tag: \`$TAG\`"
|
||||
echo "- Commit: \`$SOURCE_SHA\`"
|
||||
echo '- Assets: unchanged and checksum-verified'
|
||||
echo
|
||||
echo 'No package was rebuilt and no CDN object was uploaded.'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
7
.gitignore
vendored
7
.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
|
||||
@@ -29,3 +33,6 @@ support/sg2002/kvm_vision_test/CMakeLists.txt
|
||||
support/sg2002/additional/original
|
||||
kvmapp/server/dl_lib
|
||||
kvmapp/kvm_system/kvm_system
|
||||
|
||||
# Release packages assembled by scripts/package.sh
|
||||
/build/
|
||||
|
||||
115
CHANGELOG.md
115
CHANGELOG.md
@@ -1,3 +1,118 @@
|
||||
## 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
|
||||
|
||||
* Added LT6911D support
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Improved USB network adapter compatibility on Windows
|
||||
|
||||
## 2.4.2 (2026-05-20)
|
||||
|
||||
### Features
|
||||
|
||||
* Added HDMI capture status detection with localized warning and error overlays on the desktop screen
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fixed left-button touch cancellation and context menu cleanup for mouse input
|
||||
* Improved the WebRTC loading indicator
|
||||
* Added Wi-Fi password field validation
|
||||
* Hardened Wake-on-LAN MAC handling by normalizing stored addresses, avoiding shell execution, handling missing history gracefully, and improving named MAC display
|
||||
* Stabilized PicoClaw session switching by waiting for gateway release, refreshing runtime status before reconnecting, rendering structured thought messages, and preventing history row overflow
|
||||
|
||||
### Performance
|
||||
|
||||
* Improved H.264 streaming client handling by reducing frame queue latency, using client snapshots for fanout, applying backend ICE server configuration, and cleaning up disconnected WebRTC clients more aggressively
|
||||
|
||||
### UI Improvements
|
||||
|
||||
* Refreshed desktop menu ordering and community links, including replacing the Discussion link with Discord
|
||||
|
||||
### Chores
|
||||
|
||||
* Removed the legacy H.264 stream package
|
||||
* Updated PostCSS, refreshed the MSW worker, and added pnpm workspace build approvals
|
||||
|
||||
## 2.4.1 (2026-05-08)
|
||||
|
||||
### Features
|
||||
|
||||
73
Makefile
73
Makefile
@@ -1,19 +1,30 @@
|
||||
# Makefile for NanoKVM Project
|
||||
|
||||
# Configuration
|
||||
IMAGE_NAME := nanokvm-builder
|
||||
UID := $(shell id -u)
|
||||
GID := $(shell id -g)
|
||||
# ?= so a prebuilt image can be used instead, e.g. a GHCR-cached builder in CI.
|
||||
IMAGE_NAME ?= nanokvm-builder-local-$(UID)-$(GID)
|
||||
PWD := $(shell pwd)
|
||||
VERSION ?=
|
||||
|
||||
# Docker run common parameters
|
||||
# Docker run common parameters. Allocating a TTY breaks in environments without
|
||||
# one, so it can be overridden with DOCKER_TTY=
|
||||
DOCKER_TTY ?= -it
|
||||
DOCKER_RUN_BASE := docker run -e UID=$(UID) -e GID=$(GID) -v $(PWD):/home/build/NanoKVM --rm
|
||||
|
||||
# Build the image with the host user's identity so entrypoint does not need
|
||||
# to recursively rewrite the MaixCDK home directory at container startup.
|
||||
DOCKER_BUILD_ARGS := --build-arg DOCKER_UID=$(UID) --build-arg DOCKER_GID=$(GID)
|
||||
|
||||
# Build commands
|
||||
GO_BUILD_CMD := cd /home/build/NanoKVM/server && go mod tidy && CGO_ENABLED=1 GOOS=linux GOARCH=riscv64 CC=riscv64-unknown-linux-musl-gcc CGO_CFLAGS="-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d" go build
|
||||
SUPPORT_BUILD_CMD := . ./home/build/MaixCDK/bin/activate && cd /home/build/NanoKVM/support/sg2002 && ./build kvm_system && ./build kvm_system add_to_kvmapp
|
||||
VISION_BUILD_CMD := . ./home/build/MaixCDK/bin/activate && cd /home/build/NanoKVM/support/sg2002 && ./build kvm_vision && ./build kvm_vision add_to_kvmapp
|
||||
RELEASE_BUILD_CMD := /home/build/NanoKVM/scripts/build-in-container.sh
|
||||
|
||||
.PHONY: help check-root builder-image rebuild-image check-image shell app support all clean
|
||||
.PHONY: help check-root builder-image rebuild-image check-image shell app support vision \
|
||||
web release-build package release all clean
|
||||
|
||||
# Default target
|
||||
all: app support
|
||||
@@ -29,8 +40,13 @@ help:
|
||||
@echo " rebuild-image - Force rebuild Docker image"
|
||||
@echo " shell - Enter interactive builder environment"
|
||||
@echo " app - Build Go application server"
|
||||
@echo " support - Build hardware support libraries"
|
||||
@echo " support - Build kvm_system daemon"
|
||||
@echo " vision - Build video libraries (libkvm.so)"
|
||||
@echo " web - Build the frontend into web/dist"
|
||||
@echo " all - Build both app and support (default)"
|
||||
@echo " release-build - Build every riscv64 release artifact in one pass"
|
||||
@echo " package - Assemble nanokvm_<VERSION>.tar.gz + latest.json"
|
||||
@echo " release - release-build + web + package (needs VERSION=x.y.z)"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo ""
|
||||
@echo "Prerequisites:"
|
||||
@@ -58,7 +74,7 @@ check-image: check-root
|
||||
builder-image: check-root
|
||||
@if ! docker image inspect $(IMAGE_NAME) >/dev/null 2>&1; then \
|
||||
echo "Building Docker image..."; \
|
||||
docker build -t $(IMAGE_NAME) -f docker/Dockerfile ./; \
|
||||
docker build $(DOCKER_BUILD_ARGS) -t $(IMAGE_NAME) -f docker/Dockerfile ./; \
|
||||
else \
|
||||
echo "Docker image $(IMAGE_NAME) already exists."; \
|
||||
fi
|
||||
@@ -66,7 +82,7 @@ builder-image: check-root
|
||||
# Force rebuild Docker image
|
||||
rebuild-image: check-root
|
||||
@echo "Force rebuilding Docker image..."
|
||||
@docker build --no-cache -t $(IMAGE_NAME) -f docker/Dockerfile ./
|
||||
@docker build --no-cache $(DOCKER_BUILD_ARGS) -t $(IMAGE_NAME) -f docker/Dockerfile ./
|
||||
|
||||
# Enter interactive shell (equivalent to build.sh with no arguments)
|
||||
shell: check-root builder-image
|
||||
@@ -76,12 +92,43 @@ shell: check-root builder-image
|
||||
# Build Go application
|
||||
app: check-root builder-image
|
||||
@echo "Building app..."
|
||||
@$(DOCKER_RUN_BASE) -it $(IMAGE_NAME) /bin/bash -c '$(GO_BUILD_CMD)'
|
||||
@$(DOCKER_RUN_BASE) $(DOCKER_TTY) $(IMAGE_NAME) /bin/bash -c '$(GO_BUILD_CMD)'
|
||||
|
||||
# Build hardware support libraries
|
||||
support: check-root builder-image
|
||||
@echo "Building support..."
|
||||
@$(DOCKER_RUN_BASE) -it $(IMAGE_NAME) /bin/bash -c '$(SUPPORT_BUILD_CMD)'
|
||||
@$(DOCKER_RUN_BASE) $(DOCKER_TTY) $(IMAGE_NAME) /bin/bash -c '$(SUPPORT_BUILD_CMD)'
|
||||
|
||||
# Build video libraries (libkvm.so) into kvmapp/server/dl_lib
|
||||
vision: check-root builder-image
|
||||
@echo "Building vision..."
|
||||
@$(DOCKER_RUN_BASE) $(DOCKER_TTY) $(IMAGE_NAME) /bin/bash -c '$(VISION_BUILD_CMD)'
|
||||
|
||||
# Build every riscv64 release artifact in one container pass
|
||||
release-build: check-root builder-image
|
||||
@echo "Building release artifacts..."
|
||||
@$(DOCKER_RUN_BASE) $(DOCKER_TTY) $(IMAGE_NAME) /bin/bash -c '$(RELEASE_BUILD_CMD)'
|
||||
|
||||
# Build the frontend (runs on the host; the builder image has no Node)
|
||||
web:
|
||||
@echo "Building web..."
|
||||
@cd web && pnpm install --frozen-lockfile && pnpm build
|
||||
|
||||
# Assemble the release package and its manifest
|
||||
package:
|
||||
@if [ -z "$(VERSION)" ]; then \
|
||||
echo "VERSION is required, e.g. make package VERSION=2.4.4"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@scripts/package.sh "$(VERSION)"
|
||||
|
||||
# Full local release: riscv64 artifacts + frontend + package.
|
||||
# Invoked as sub-makes rather than prerequisites: packaging asserts on what the
|
||||
# earlier steps produce, so the order matters even under make -j.
|
||||
release:
|
||||
@$(MAKE) release-build
|
||||
@$(MAKE) web
|
||||
@$(MAKE) package VERSION="$(VERSION)"
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
@@ -94,4 +141,12 @@ clean:
|
||||
rm -rf support/sg2002/build; \
|
||||
echo "Removed support/sg2002/build"; \
|
||||
fi
|
||||
@echo "Clean completed."
|
||||
@if [ -d build/release ]; then \
|
||||
rm -rf build/release; \
|
||||
echo "Removed build/release"; \
|
||||
fi
|
||||
@if [ -d web/dist ]; then \
|
||||
rm -rf web/dist; \
|
||||
echo "Removed web/dist"; \
|
||||
fi
|
||||
@echo "Clean completed."
|
||||
|
||||
13
README.md
13
README.md
@@ -87,9 +87,22 @@ Start with the guide that matches the part of NanoKVM you want to work on:
|
||||
- **System support modules:** Build and update the low-level hardware support components in [support/sg2002/README.md](support/sg2002/README.md).
|
||||
- **Backend service:** Set up, build, and understand the Go service in [server/README.md](server/README.md).
|
||||
- **Frontend UI:** Develop, lint, and build the React interface in [web/README.md](web/README.md).
|
||||
- **Release packaging:** Assemble the `nanokvm_<version>.tar.gz` update package in [scripts/README.md](scripts/README.md).
|
||||
|
||||
> 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).
|
||||
|
||||
@@ -3,11 +3,24 @@ FROM ubuntu:24.04 AS base_apt
|
||||
# RUN echo 'Acquire::http::Proxy "http://cache.lan:3142";' > /etc/apt/apt.conf.d/00cacher
|
||||
RUN userdel -r ubuntu
|
||||
RUN apt update \
|
||||
&& apt install wget git cmake build-essential python3 python3-venv python3-pip autoconf automake libtool gosu -y
|
||||
&& apt install wget git cmake build-essential python3 python3-venv python3-pip autoconf automake libtool gosu patchelf -y
|
||||
|
||||
ARG DOCKER_USER=build
|
||||
RUN useradd -m "$DOCKER_USER"
|
||||
USER $DOCKER_USER
|
||||
ARG DOCKER_UID=1000
|
||||
ARG DOCKER_GID=1000
|
||||
# Reuse existing accounts for numeric IDs that are already present in the
|
||||
# base image. All later ownership and user switches use numeric IDs, so a
|
||||
# dedicated build user/group is only needed when the ID does not exist.
|
||||
RUN set -eux; \
|
||||
if ! getent group "$DOCKER_GID" >/dev/null; then \
|
||||
groupadd --gid "$DOCKER_GID" "$DOCKER_USER"; \
|
||||
fi; \
|
||||
if ! getent passwd "$DOCKER_UID" >/dev/null; then \
|
||||
useradd --uid "$DOCKER_UID" --gid "$DOCKER_GID" --no-create-home \
|
||||
--home-dir "/home/$DOCKER_USER" --shell /bin/bash "$DOCKER_USER"; \
|
||||
fi; \
|
||||
install -d -o "$DOCKER_UID" -g "$DOCKER_GID" "/home/$DOCKER_USER"
|
||||
USER $DOCKER_UID:$DOCKER_GID
|
||||
|
||||
FROM base_apt AS host_tools
|
||||
|
||||
@@ -39,6 +52,8 @@ RUN cd /tmp/go_cache \
|
||||
|
||||
FROM base_apt AS sdk
|
||||
|
||||
ENV HOME="/home/$DOCKER_USER"
|
||||
|
||||
RUN cd ~ \
|
||||
&& git clone https://github.com/Sipeed/MaixCDK \
|
||||
&& cd MaixCDK \
|
||||
@@ -46,7 +61,7 @@ RUN cd ~ \
|
||||
&& . ./bin/activate \
|
||||
&& pip install -U -r requirements.txt
|
||||
|
||||
COPY --chown=$DOCKER_USER . /home/$DOCKER_USER/NanoKVM
|
||||
COPY --chown=$DOCKER_UID:$DOCKER_GID . /home/$DOCKER_USER/NanoKVM
|
||||
|
||||
RUN cd ~/MaixCDK \
|
||||
&& . ./bin/activate \
|
||||
@@ -57,7 +72,7 @@ FROM base_apt
|
||||
|
||||
# Install golang
|
||||
COPY --from=golang /usr/local/go /usr/local/go
|
||||
COPY --chown=$DOCKER_USER --from=golang /root/go /home/$DOCKER_USER/go
|
||||
COPY --chown=$DOCKER_UID:$DOCKER_GID --from=golang /root/go /home/$DOCKER_USER/go
|
||||
ENV PATH="$PATH:/usr/local/go/bin"
|
||||
|
||||
# Install host tools
|
||||
@@ -65,7 +80,7 @@ COPY --from=host_tools /usr/local/host-tools /usr/local/host-tools
|
||||
ENV PATH="$PATH:/usr/local/host-tools/gcc/riscv64-linux-musl-x86_64/bin"
|
||||
|
||||
# Install SDK
|
||||
COPY --from=sdk /home/$DOCKER_USER/MaixCDK /home/$DOCKER_USER/MaixCDK
|
||||
COPY --chown=$DOCKER_UID:$DOCKER_GID --from=sdk /home/$DOCKER_USER/MaixCDK /home/$DOCKER_USER/MaixCDK
|
||||
COPY --chmod=+x docker/entrypoint /entrypoint
|
||||
|
||||
RUN echo "Verify build tools" \
|
||||
@@ -76,4 +91,4 @@ USER root
|
||||
RUN chmod 0755 /entrypoint \
|
||||
&& sed "s/\$DOCKER_USER/$DOCKER_USER/g" -i /entrypoint
|
||||
|
||||
ENTRYPOINT ["/entrypoint"]
|
||||
ENTRYPOINT ["/entrypoint"]
|
||||
|
||||
10
docker/entrypoint
Normal file → Executable file
10
docker/entrypoint
Normal file → Executable file
@@ -12,11 +12,9 @@ fi
|
||||
|
||||
if [ "$UID" != 0 ]
|
||||
then
|
||||
usermod -u "$UID" "$DOCKER_USER" 2>/dev/null && {
|
||||
groupmod -g "$GID" "$DOCKER_USER" 2>/dev/null ||
|
||||
usermod -a -G "$GID" "$DOCKER_USER"
|
||||
}
|
||||
set -- gosu "${UID}:${GID}" "${@}"
|
||||
set -- gosu "${UID}:${GID}" env HOME="/home/$DOCKER_USER" "${@}"
|
||||
else
|
||||
export HOME=/root
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
exec "$@"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/sh
|
||||
# kvmhwd Rev2.1
|
||||
# kvmhwd Rev2.2
|
||||
|
||||
start_usb_dev(){
|
||||
. /etc/profile
|
||||
@@ -23,6 +23,11 @@ start_usb_dev(){
|
||||
else
|
||||
echo 0x1009 > idProduct
|
||||
fi
|
||||
|
||||
echo 0xEF > bDeviceClass
|
||||
echo 0x02 > bDeviceSubClass
|
||||
echo 0x01 > bDeviceProtocol
|
||||
|
||||
mkdir strings/0x409
|
||||
echo '0123456789ABCDEF' > strings/0x409/serialnumber
|
||||
echo 'sipeed' > strings/0x409/manufacturer
|
||||
@@ -34,18 +39,48 @@ start_usb_dev(){
|
||||
mkdir configs/c.1/strings/0x409
|
||||
echo "NanoKVM" > configs/c.1/strings/0x409/configuration
|
||||
|
||||
# Deterministic gadget MACs from the chip UID so the host-side RNDIS/NCM
|
||||
# interface keeps a STABLE MAC across re-enumeration/reboot. Without this
|
||||
# the kernel assigns a random dev_addr/host_addr on every bind, so the
|
||||
# attached PC sees a brand-new network adapter (and MAC) each time.
|
||||
usb_uid=$(sha512sum /sys/class/cvi-base/base_uid | head -c 4)
|
||||
if [ -n "$usb_uid" ]
|
||||
then
|
||||
usb_dev_mac="48:da:35:6e:${usb_uid%??}:${usb_uid#??}"
|
||||
usb_host_mac="48:da:35:6d:${usb_uid%??}:${usb_uid#??}"
|
||||
fi
|
||||
|
||||
if [ -e /boot/usb.ncm ]
|
||||
then
|
||||
mkdir functions/ncm.usb0
|
||||
[ -n "$usb_dev_mac" ] && echo "$usb_dev_mac" > functions/ncm.usb0/dev_addr
|
||||
[ -n "$usb_host_mac" ] && echo "$usb_host_mac" > functions/ncm.usb0/host_addr
|
||||
echo WINNCM > functions/ncm.usb0/os_desc/interface.ncm/compatible_id
|
||||
ln -s functions/ncm.usb0 configs/c.1/
|
||||
else
|
||||
if [ -e /boot/usb.rndis0 ]
|
||||
then
|
||||
mkdir functions/rndis.usb0
|
||||
[ -n "$usb_dev_mac" ] && echo "$usb_dev_mac" > functions/rndis.usb0/dev_addr
|
||||
[ -n "$usb_host_mac" ] && echo "$usb_host_mac" > functions/rndis.usb0/host_addr
|
||||
echo e0 > functions/rndis.usb0/class
|
||||
echo 01 > functions/rndis.usb0/subclass
|
||||
echo 03 > functions/rndis.usb0/protocol
|
||||
echo RNDIS > functions/rndis.usb0/os_desc/interface.rndis/compatible_id
|
||||
echo 5162001 > functions/rndis.usb0/os_desc/interface.rndis/sub_compatible_id
|
||||
ln -s functions/rndis.usb0 configs/c.1/
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -e /boot/usb.ncm ] || [ -e /boot/usb.rndis0 ]
|
||||
then
|
||||
# Microsoft OS 2.0 Descriptor
|
||||
echo 1 > os_desc/use
|
||||
echo 0xCD > os_desc/b_vendor_code
|
||||
echo MSFT100 > os_desc/qw_sign
|
||||
ln -s configs/c.1 os_desc
|
||||
fi
|
||||
|
||||
if [ ! -e /boot/disable_hid ]
|
||||
then
|
||||
# keyboard
|
||||
@@ -99,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,5 +1,5 @@
|
||||
#!/bin/sh
|
||||
# kvmhwd Rev2.4
|
||||
# kvmhwd Rev2.5
|
||||
|
||||
Alpha_OLED_RST_Pin=371
|
||||
Beta_OLED_RST_Pin=502
|
||||
@@ -25,7 +25,7 @@ init_alpha_hw(){
|
||||
devmem 0x03001060 32 0x3 # GPIOA24
|
||||
devmem 0x03001054 32 0x3 # GPIOA25
|
||||
devmem 0x03001058 32 0x3 # GPIOA27
|
||||
|
||||
|
||||
devmem 0x03001068 32 0x6 # GPIOA 18 UART1 RX
|
||||
devmem 0x03001064 32 0x6 # GPIOA 19 UART1 TX
|
||||
devmem 0x03001070 32 0x2 # GPIOA 28 UART2 TX
|
||||
@@ -146,19 +146,7 @@ kvm_hw_detect(){
|
||||
insmod /mnt/system/ko/i2c-algo-bit.ko
|
||||
insmod /mnt/system/ko/i2c-gpio.ko
|
||||
|
||||
kvm_tmp=$(i2cdetect -ry 4 0x2c 0x2c | grep 2c)
|
||||
if [ -n "$kvm_tmp" ]
|
||||
then
|
||||
echo "c" > /etc/kvm/hdmi_version
|
||||
else
|
||||
kvm_tmp1=$(i2cdetect -ry 4 0x2b 0x2b | grep 2b)
|
||||
if [ -n "$kvm_tmp1" ]
|
||||
then
|
||||
echo "ux" > /etc/kvm/hdmi_version
|
||||
else
|
||||
echo "ue" > /etc/kvm/hdmi_version
|
||||
fi
|
||||
fi
|
||||
kvm_hdmi_version_detect
|
||||
|
||||
kvm_tmp=$(i2cdetect -ry 5 0x3c 0x3c | grep 3c)
|
||||
if [ -n "$kvm_tmp" ]
|
||||
@@ -199,20 +187,19 @@ kvm_hw_init(){
|
||||
fi
|
||||
}
|
||||
|
||||
kvm_hdmi_version_detect(){
|
||||
hdmi_tmp=$(i2cdetect -ry 4 0x2c 0x2c | grep 2c)
|
||||
if [ -n "$hdmi_tmp" ]
|
||||
then
|
||||
echo "c" > /etc/kvm/hdmi_version
|
||||
else
|
||||
hdmi_tmp1=$(i2cdetect -ry 4 0x2b 0x2b | grep 2b)
|
||||
if [ -n "$hdmi_tmp1" ]
|
||||
then
|
||||
echo "ux" > /etc/kvm/hdmi_version
|
||||
kvm_hdmi_version_detect()
|
||||
{
|
||||
if i2cdetect -ry 4 0x2c 0x2c | grep -qw "2c"; then
|
||||
echo "c" > /etc/kvm/hdmi_version
|
||||
elif i2cdetect -ry 4 0x2b 0x2b | grep -qw "2b"; then
|
||||
if i2cdetect -ry 4 0x44 0x44 | grep -qw "44"; then
|
||||
echo "ux" > /etc/kvm/hdmi_version
|
||||
else
|
||||
echo "d" > /etc/kvm/hdmi_version
|
||||
fi
|
||||
else
|
||||
echo "ue" > /etc/kvm/hdmi_version
|
||||
echo "ue" > /etc/kvm/hdmi_version
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,12 +21,7 @@ resolve_user_home() {
|
||||
printf '%s\n' "/root"
|
||||
}
|
||||
|
||||
setup_runtime_env() {
|
||||
[ -x "$BIN_PATH" ] || {
|
||||
echo "picoclaw binary not found: $BIN_PATH"
|
||||
return 1
|
||||
}
|
||||
|
||||
setup_picoclaw_home() {
|
||||
USER_HOME="$(resolve_user_home)"
|
||||
PICOCLAW_HOME="${PICOCLAW_HOME:-$USER_HOME/.picoclaw}"
|
||||
HOME="$USER_HOME"
|
||||
@@ -37,6 +32,8 @@ setup_runtime_env() {
|
||||
echo "failed to create picoclaw home: $PICOCLAW_HOME"
|
||||
return 1
|
||||
}
|
||||
PICOCLAW_PID_FILE="${PICOCLAW_PID_FILE:-$PICOCLAW_HOME/.picoclaw.pid}"
|
||||
export PICOCLAW_PID_FILE
|
||||
|
||||
cd "$PICOCLAW_HOME" || {
|
||||
echo "failed to enter picoclaw home: $PICOCLAW_HOME"
|
||||
@@ -44,6 +41,75 @@ setup_runtime_env() {
|
||||
}
|
||||
}
|
||||
|
||||
setup_runtime_env() {
|
||||
[ -x "$BIN_PATH" ] || {
|
||||
echo "picoclaw binary not found: $BIN_PATH"
|
||||
return 1
|
||||
}
|
||||
|
||||
setup_picoclaw_home || return 1
|
||||
}
|
||||
|
||||
read_pid_file() {
|
||||
[ -f "$PICOCLAW_PID_FILE" ] || return 1
|
||||
pid="$(sed -n '1p' "$PICOCLAW_PID_FILE" 2>/dev/null | tr -d '[:space:]')"
|
||||
case "$pid" in
|
||||
''|*[!0-9]*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
printf '%s\n' "$pid"
|
||||
}
|
||||
|
||||
is_gateway_pid() {
|
||||
pid="$1"
|
||||
[ -n "$pid" ] || return 1
|
||||
[ -r "/proc/$pid/cmdline" ] || return 1
|
||||
|
||||
cmd_path="$(tr '\000' '\n' <"/proc/$pid/cmdline" 2>/dev/null | sed -n '1p')"
|
||||
subcommand="$(tr '\000' '\n' <"/proc/$pid/cmdline" 2>/dev/null | sed -n '2p')"
|
||||
[ "$(basename "$cmd_path")" = "$BIN_NAME" ] && [ "$subcommand" = "gateway" ] && return 0
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
find_gateway_pid() {
|
||||
for proc_dir in /proc/[0-9]*; do
|
||||
[ -d "$proc_dir" ] || continue
|
||||
pid="${proc_dir#/proc/}"
|
||||
if is_gateway_pid "$pid"; then
|
||||
printf '%s\n' "$pid"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
cleanup_stale_pid_file() {
|
||||
[ -f "$PICOCLAW_PID_FILE" ] || return 0
|
||||
pid="$(read_pid_file 2>/dev/null)" || {
|
||||
rm -f "$PICOCLAW_PID_FILE"
|
||||
return 0
|
||||
}
|
||||
if ! is_gateway_pid "$pid"; then
|
||||
echo "picoclaw stale pid file removed: $PICOCLAW_PID_FILE"
|
||||
rm -f "$PICOCLAW_PID_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
gateway_running_pid() {
|
||||
pid="$(read_pid_file 2>/dev/null)" && is_gateway_pid "$pid" && {
|
||||
printf '%s\n' "$pid"
|
||||
return 0
|
||||
}
|
||||
|
||||
cleanup_stale_pid_file
|
||||
pid="$(find_gateway_pid 2>/dev/null)" || return 1
|
||||
printf '%s\n' "$pid" >"$PICOCLAW_PID_FILE" 2>/dev/null || true
|
||||
printf '%s\n' "$pid"
|
||||
}
|
||||
|
||||
resolve_kvm_control_source() {
|
||||
for candidate in \
|
||||
"/kvmapp/picoclaw/skills/kvm-control" \
|
||||
@@ -107,8 +173,8 @@ sync_agent_profile() {
|
||||
start_service() {
|
||||
setup_runtime_env || return 1
|
||||
|
||||
if pidof "$BIN_NAME" >/dev/null 2>&1; then
|
||||
echo "picoclaw already running"
|
||||
if pid="$(gateway_running_pid)"; then
|
||||
echo "picoclaw gateway already running (PID: $pid)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -118,6 +184,12 @@ start_service() {
|
||||
fi
|
||||
|
||||
"$BIN_PATH" gateway >>"$LOG_FILE" 2>&1 &
|
||||
gateway_pid="$!"
|
||||
printf '%s\n' "$gateway_pid" >"$PICOCLAW_PID_FILE" || {
|
||||
echo "failed to write picoclaw pid file: $PICOCLAW_PID_FILE"
|
||||
return 1
|
||||
}
|
||||
echo "picoclaw gateway started (PID: $gateway_pid)"
|
||||
}
|
||||
|
||||
onboard_service() {
|
||||
@@ -128,7 +200,26 @@ onboard_service() {
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
killall "$BIN_NAME" 2>/dev/null || true
|
||||
setup_picoclaw_home || return 1
|
||||
|
||||
pid="$(gateway_running_pid 2>/dev/null)" || {
|
||||
cleanup_stale_pid_file
|
||||
return 0
|
||||
}
|
||||
|
||||
kill "$pid" 2>/dev/null || true
|
||||
count=0
|
||||
while is_gateway_pid "$pid"; do
|
||||
count=$((count + 1))
|
||||
if [ "$count" -ge 15 ]; then
|
||||
echo "picoclaw gateway did not stop after TERM, killing PID: $pid"
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
rm -f "$PICOCLAW_PID_FILE"
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
|
||||
102
scripts/README.md
Normal file
102
scripts/README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Release packaging
|
||||
|
||||
These scripts assemble the application update package that NanoKVM devices pull
|
||||
over the air: `nanokvm_<version>.tar.gz` plus its `latest.json` manifest.
|
||||
|
||||
| Script | Purpose |
|
||||
|---|---|
|
||||
| `build-in-container.sh` | Builds every riscv64 artifact (`kvm_system`, `libkvm.so`, `NanoKVM-Server`). Runs inside the `nanokvm-builder` image only. |
|
||||
| `package.sh` | Stages the package tree, creates the tarball, and writes `latest.json`. |
|
||||
| `compare-release.sh` | Diffs a freshly built package against the currently published one. Informational. |
|
||||
| `verify-release-assets.sh` | Checks the three GitHub Release assets before publishing or promotion. |
|
||||
| `verify-release-tag.sh` | Requires an annotated numeric tag whose commit is on `main`. |
|
||||
|
||||
## What the updater expects
|
||||
|
||||
The format is not arbitrary — it is fixed by the on-device updater in
|
||||
`server/service/application/`:
|
||||
|
||||
- **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).
|
||||
- **`sha512` is base64, not hex.** `update.go` compares against
|
||||
`base64(raw sha512 digest)`. A hex digest will fail verification on device.
|
||||
- **`/kvmapp/version`** is what the device reports as its installed version, so
|
||||
it must match the `version` field.
|
||||
|
||||
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
|
||||
|
||||
The frontend needs Node and pnpm on the host; everything riscv64 needs the
|
||||
builder image (see `docker/Dockerfile`).
|
||||
|
||||
```bash
|
||||
make release VERSION=2.4.4
|
||||
```
|
||||
|
||||
That is equivalent to:
|
||||
|
||||
```bash
|
||||
make release-build # kvm_system + libkvm.so + NanoKVM-Server, in Docker
|
||||
make web # web/dist
|
||||
make package VERSION=2.4.4 # build/release/{nanokvm_2.4.4.tar.gz,latest.json}
|
||||
```
|
||||
|
||||
In CI this runs as the **NanoKVM Package** workflow. Pull requests and manual
|
||||
runs only create Actions artifacts. For a public release, run **NanoKVM Create
|
||||
Tag**, then run **NanoKVM Release** and choose prerelease, stable, or promotion.
|
||||
Publishing attaches three assets:
|
||||
|
||||
Publishing is one-shot: an existing Release or draft is an error, and only a
|
||||
published prerelease can be promoted.
|
||||
|
||||
- `nanokvm_<version>.tar.gz`
|
||||
- `latest.json`
|
||||
- `sha256.txt`
|
||||
|
||||
The Actions artifact also includes `BUILD_INFO.txt` with the source commit,
|
||||
workflow run, immutable builder image digest, and both checksum encodings; that
|
||||
provenance file is not attached to the GitHub release. Uploading the tarball and
|
||||
`latest.json` to the CDN is what actually offers the update to devices in the
|
||||
field, so that step stays manual. `sha256.txt` is only for users verifying a
|
||||
manual download and does not change the on-device `latest.json` contract.
|
||||
|
||||
## Where each file in the package comes from
|
||||
|
||||
| Package path | Source |
|
||||
|---|---|
|
||||
| `version` | the requested version |
|
||||
| `server/NanoKVM-Server` | `server/build.sh` (BoringCrypto, RPATH `$ORIGIN/dl_lib`) |
|
||||
| `server/dl_lib/` | whatever the `kvm_vision` build emits, with any library it does not emit backfilled from the tracked `server/dl_lib/` |
|
||||
| `server/web/` | `web/dist/` |
|
||||
| `kvm_system/kvm_system` | `support/sg2002` `build kvm_system` |
|
||||
| `system/tool/` | prebuilt binaries in `tools/nanokvm_update_edid/` |
|
||||
| `kvm/` | default runtime state (resolution, fps, quality) |
|
||||
| everything else | tracked `kvmapp/` |
|
||||
|
||||
Note that `kvm/` ships default runtime state, and `install.go` replaces
|
||||
`/kvmapp` wholesale, so an update resets those values on the device. That is
|
||||
long-standing behaviour, not something these scripts introduce.
|
||||
|
||||
## The two copies of libkvm.so
|
||||
|
||||
`NanoKVM-Server` is cgo-linked against the **tracked** `server/dl_lib/libkvm.so`
|
||||
(`server/common/kvm_vision.go`: `-L../dl_lib -lkvm`), but the package ships the
|
||||
library the `kvm_vision` build just produced. Those are two different files, and
|
||||
the tracked one can lag well behind — published releases have shipped this
|
||||
mismatch for a long time.
|
||||
|
||||
Because a symbol the binary imports could in principle be absent from the
|
||||
shipped library, and that would only surface as a crash on a real device,
|
||||
`build-in-container.sh` checks the shipped `libkvm.so` still exports every
|
||||
symbol `NanoKVM-Server` actually imports from it, and fails the build otherwise.
|
||||
144
scripts/build-in-container.sh
Executable file
144
scripts/build-in-container.sh
Executable file
@@ -0,0 +1,144 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Build every riscv64 artifact of a release, from inside the nanokvm-builder
|
||||
# image (docker/Dockerfile). Not meant to be run on the host: it needs the
|
||||
# riscv64 musl toolchain and the patched MaixCDK checkout that the image ships.
|
||||
#
|
||||
# Produces:
|
||||
# kvmapp/kvm_system/kvm_system kvm_system daemon
|
||||
# kvmapp/server/dl_lib/ video libraries, including a fresh libkvm.so
|
||||
# server/NanoKVM-Server Go server (BoringCrypto, RPATH $ORIGIN/dl_lib)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# docker/entrypoint sets HOME for the build user. support/sg2002/build resolves
|
||||
# both MaixCDK and the source tree through ~, so a wrong HOME would send the
|
||||
# whole build at /root; say so plainly rather than emitting "directory missing".
|
||||
BUILD_HOME="${BUILD_HOME:-/home/build}"
|
||||
if [ "${HOME:-}" != "$BUILD_HOME" ]; then
|
||||
echo "[ERROR] HOME is '${HOME:-unset}', expected $BUILD_HOME" >&2
|
||||
echo " run this inside the builder image via the Makefile" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MAIXCDK_PATH="$HOME/MaixCDK"
|
||||
NANOKVM_PATH="$HOME/NanoKVM"
|
||||
|
||||
for dir in "$MAIXCDK_PATH" "$NANOKVM_PATH"; do
|
||||
if [ ! -d "$dir" ]; then
|
||||
echo "[ERROR] $dir not found - is this running inside nanokvm-builder?" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Check the toolchain up front. builder-image reuses any existing image, so an
|
||||
# image built before a tooling change silently lacks it - and without this the
|
||||
# SDK build would run for minutes before server/build.sh failed at the end.
|
||||
for tool in go riscv64-unknown-linux-musl-gcc patchelf; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
echo "[ERROR] '$tool' is missing from the builder image" >&2
|
||||
echo " the image is out of date; rebuild it with: make rebuild-image" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# The SDK tree is owned by the ids baked into the image (DOCKER_UID/DOCKER_GID),
|
||||
# while the entrypoint drops to the caller's ids. If they disagree, syncing
|
||||
# components fails partway through a long build; catch it here instead.
|
||||
if [ ! -w "$MAIXCDK_PATH/components" ]; then
|
||||
echo "[ERROR] $MAIXCDK_PATH/components is not writable by $(id -u):$(id -g)" >&2
|
||||
echo " the image was built for different ids; rebuild it with:" >&2
|
||||
echo " make rebuild-image" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
. "$MAIXCDK_PATH/bin/activate"
|
||||
|
||||
echo "::group::support: sync MaixCDK components"
|
||||
cd "$NANOKVM_PATH/support/sg2002"
|
||||
# The image bakes support/sg2002/additional/ into MaixCDK's components at image
|
||||
# build time, and ./build only re-copies them when components/kvm is missing.
|
||||
# Refresh unconditionally so a cached image never yields stale libraries.
|
||||
./build update_lib
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::support: build kvm_system"
|
||||
./build kvm_system
|
||||
./build kvm_system add_to_kvmapp
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::support: build kvm_vision (libkvm.so)"
|
||||
./build kvm_vision
|
||||
./build kvm_vision add_to_kvmapp
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::server: cross-compile NanoKVM-Server"
|
||||
cd "$NANOKVM_PATH/server"
|
||||
./build.sh
|
||||
echo "::endgroup::"
|
||||
|
||||
# support/sg2002/build reports "Build Error!" without a non-zero exit status for
|
||||
# some failure paths, so assert on the artifacts rather than trusting $?.
|
||||
echo "[INFO] verifying artifacts"
|
||||
missing=0
|
||||
for artifact in \
|
||||
"$NANOKVM_PATH/kvmapp/kvm_system/kvm_system" \
|
||||
"$NANOKVM_PATH/kvmapp/server/dl_lib/libkvm.so" \
|
||||
"$NANOKVM_PATH/server/NanoKVM-Server"
|
||||
do
|
||||
if [ -f "$artifact" ]; then
|
||||
echo " ok ${artifact#$NANOKVM_PATH/}"
|
||||
else
|
||||
echo " MISSING ${artifact#$NANOKVM_PATH/}" >&2
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$missing" -ne 0 ]; then
|
||||
echo "[ERROR] build did not produce all expected artifacts" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# NanoKVM-Server is cgo-linked against the tracked server/dl_lib/libkvm.so
|
||||
# (server/common/kvm_vision.go: -L../dl_lib -lkvm), but the package ships the
|
||||
# freshly built one from kvm_vision. Those are different files, so confirm the
|
||||
# shipped library still exports every symbol the binary actually imports from
|
||||
# it. Without this a dropped symbol only shows up as a crash on a real device.
|
||||
echo "[INFO] verifying shipped libkvm.so satisfies NanoKVM-Server"
|
||||
if ! command -v readelf >/dev/null 2>&1; then
|
||||
echo "[WARN] readelf unavailable, skipping ABI check"
|
||||
else
|
||||
exported() {
|
||||
readelf --dyn-syms --wide "$1" 2>/dev/null \
|
||||
| awk '$7 != "UND" && $8 != "" { print $8 }' \
|
||||
| sed 's/@.*//' | LC_ALL=C sort -u
|
||||
}
|
||||
imported() {
|
||||
readelf --dyn-syms --wide "$1" 2>/dev/null \
|
||||
| awk '$7 == "UND" && $8 != "" { print $8 }' \
|
||||
| sed 's/@.*//' | LC_ALL=C sort -u
|
||||
}
|
||||
|
||||
linked_lib="$NANOKVM_PATH/server/dl_lib/libkvm.so"
|
||||
shipped_lib="$NANOKVM_PATH/kvmapp/server/dl_lib/libkvm.so"
|
||||
server_bin="$NANOKVM_PATH/server/NanoKVM-Server"
|
||||
|
||||
# Symbols the binary imports that the link-time library provides, i.e. the
|
||||
# ones libkvm.so is actually responsible for at runtime.
|
||||
needed=$(comm -12 <(imported "$server_bin") <(exported "$linked_lib"))
|
||||
|
||||
if [ -z "$needed" ]; then
|
||||
echo "[WARN] no libkvm symbols resolved; skipping ABI check"
|
||||
else
|
||||
absent=$(comm -23 <(printf '%s\n' "$needed") <(exported "$shipped_lib"))
|
||||
if [ -n "$absent" ]; then
|
||||
echo "[ERROR] shipped libkvm.so is missing symbols NanoKVM-Server needs:" >&2
|
||||
printf ' %s\n' $absent >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " ok $(printf '%s\n' "$needed" | wc -l | tr -d ' ') libkvm symbols resolve"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[DONE] riscv64 artifacts built"
|
||||
103
scripts/compare-release.sh
Executable file
103
scripts/compare-release.sh
Executable file
@@ -0,0 +1,103 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Compare a freshly built package against the currently published release.
|
||||
#
|
||||
# Purely informational: it exits 0 whatever it finds. The point is to make the
|
||||
# delta reviewable before the tarball is published, since the package is what
|
||||
# every device pulls over OTA. Unexpected entries here (a library that vanished,
|
||||
# an init script that changed without a matching commit) are worth a look.
|
||||
#
|
||||
# Usage: scripts/compare-release.sh <new-tarball> [base-url]
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
NEW_TARBALL="${1:-}"
|
||||
BASE_URL="${2:-https://cdn.sipeed.com/nanokvm}"
|
||||
|
||||
if [ -z "$NEW_TARBALL" ] || [ ! -f "$NEW_TARBALL" ]; then
|
||||
echo "Usage: $0 <new-tarball> [base-url]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
echo "[INFO] fetching published manifest from $BASE_URL/latest.json"
|
||||
if ! curl -fsSL "$BASE_URL/latest.json" -o "$WORK/latest.json"; then
|
||||
echo "[WARN] could not fetch latest.json - skipping comparison"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cat "$WORK/latest.json"
|
||||
|
||||
OLD_NAME="$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$WORK/latest.json")"
|
||||
if [ -z "$OLD_NAME" ]; then
|
||||
echo "[WARN] no \"name\" in latest.json - skipping comparison"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[INFO] downloading published package $OLD_NAME"
|
||||
if ! curl -fsSL "$BASE_URL/$OLD_NAME" -o "$WORK/$OLD_NAME"; then
|
||||
echo "[WARN] could not download $OLD_NAME - skipping comparison"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$WORK/old" "$WORK/new"
|
||||
if ! tar xzf "$WORK/$OLD_NAME" -C "$WORK/old"; then
|
||||
echo "[WARN] could not extract $OLD_NAME - skipping comparison"
|
||||
exit 0
|
||||
fi
|
||||
if ! tar xzf "$NEW_TARBALL" -C "$WORK/new"; then
|
||||
echo "[WARN] could not extract $(basename "$NEW_TARBALL") - skipping comparison"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Strip the nanokvm_<version>/ prefix so the two trees are comparable.
|
||||
OLD_ROOT="$(find "$WORK/old" -mindepth 1 -maxdepth 1 -type d | head -1)"
|
||||
NEW_ROOT="$(find "$WORK/new" -mindepth 1 -maxdepth 1 -type d | head -1)"
|
||||
|
||||
if [ -z "$OLD_ROOT" ] || [ -z "$NEW_ROOT" ]; then
|
||||
echo "[WARN] could not locate a package root directory - skipping comparison"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
( cd "$OLD_ROOT" && find . -type f | sed 's|^\./||' | LC_ALL=C sort ) > "$WORK/old.list"
|
||||
( cd "$NEW_ROOT" && find . -type f | sed 's|^\./||' | LC_ALL=C sort ) > "$WORK/new.list"
|
||||
|
||||
# An empty inventory would make every section below print "(none)", which reads
|
||||
# identically to "verified, nothing changed". Refuse to imply that.
|
||||
if [ ! -s "$WORK/old.list" ] || [ ! -s "$WORK/new.list" ]; then
|
||||
echo "[WARN] one of the packages produced an empty file list - comparison unreliable, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=============================================================="
|
||||
echo " $(basename "$OLD_ROOT") -> $(basename "$NEW_ROOT")"
|
||||
echo "=============================================================="
|
||||
printf ' files: %s -> %s\n' "$(wc -l < "$WORK/old.list" | tr -d ' ')" \
|
||||
"$(wc -l < "$WORK/new.list" | tr -d ' ')"
|
||||
|
||||
echo
|
||||
echo "--- added ----------------------------------------------------"
|
||||
comm -13 "$WORK/old.list" "$WORK/new.list" | sed 's/^/ + /' || true
|
||||
|
||||
echo
|
||||
echo "--- removed --------------------------------------------------"
|
||||
comm -23 "$WORK/old.list" "$WORK/new.list" | sed 's/^/ - /' || true
|
||||
|
||||
echo
|
||||
echo "--- changed --------------------------------------------------"
|
||||
changed=0
|
||||
while IFS= read -r file; do
|
||||
if ! cmp -s "$OLD_ROOT/$file" "$NEW_ROOT/$file"; then
|
||||
old_size="$(wc -c < "$OLD_ROOT/$file" | tr -d ' ')"
|
||||
new_size="$(wc -c < "$NEW_ROOT/$file" | tr -d ' ')"
|
||||
printf ' ~ %-52s %s -> %s bytes\n' "$file" "$old_size" "$new_size"
|
||||
changed=$((changed + 1))
|
||||
fi
|
||||
done < <(comm -12 "$WORK/old.list" "$WORK/new.list")
|
||||
[ "$changed" -eq 0 ] && echo " (none)"
|
||||
|
||||
echo
|
||||
echo "[INFO] comparison complete"
|
||||
231
scripts/package.sh
Executable file
231
scripts/package.sh
Executable file
@@ -0,0 +1,231 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Assemble a NanoKVM release package: nanokvm_<version>.tar.gz + latest.json.
|
||||
#
|
||||
# The layout and the manifest fields are dictated by the on-device updater:
|
||||
# - server/service/application/version.go parses latest.json and derives the
|
||||
# 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/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):
|
||||
# server/NanoKVM-Server riscv64 server binary
|
||||
# kvmapp/kvm_system/kvm_system kvm_system daemon
|
||||
# kvmapp/server/dl_lib/libkvm.so freshly built video library (optional)
|
||||
# web/dist/ built frontend
|
||||
#
|
||||
# Usage: scripts/package.sh <version>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
VERSION="${1:-}"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Usage: $0 <version> e.g. $0 2.4.4" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The updater rejects file names outside [a-zA-Z0-9._-] (update_offline.go),
|
||||
# and the version string ends up in the file name, so validate it up front.
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "[ERROR] invalid version '$VERSION', expected MAJOR.MINOR.PATCH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUT="$ROOT/build/release"
|
||||
STAGE="$OUT/nanokvm_$VERSION"
|
||||
TARBALL="$OUT/nanokvm_$VERSION.tar.gz"
|
||||
MANIFEST="$OUT/latest.json"
|
||||
|
||||
require_file() {
|
||||
if [ ! -f "$1" ]; then
|
||||
echo "[ERROR] missing build artifact: ${1#"$ROOT"/}" >&2
|
||||
echo " $2" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# A host-architecture build is indistinguishable from a cross-compiled one by
|
||||
# name alone, and NanoKVM-Server is gitignored, so nothing else would catch it.
|
||||
# Refuse to package anything that is not riscv64 (ELF e_machine 243).
|
||||
require_riscv64() {
|
||||
local path="$1" magic machine
|
||||
magic=$(od -An -tx1 -N4 "$path" | tr -d ' \n')
|
||||
if [ "$magic" != "7f454c46" ]; then
|
||||
echo "[ERROR] not an ELF object: ${path#"$ROOT"/}" >&2
|
||||
exit 1
|
||||
fi
|
||||
machine=$(od -An -tu1 -j18 -N1 "$path" | tr -d ' \n')
|
||||
if [ "$machine" != "243" ]; then
|
||||
echo "[ERROR] ${path#"$ROOT"/} is not riscv64 (ELF e_machine=$machine)" >&2
|
||||
echo " a host-architecture build must never be packaged" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_file "$ROOT/server/NanoKVM-Server" "run: make app"
|
||||
require_file "$ROOT/kvmapp/kvm_system/kvm_system" "run: make support"
|
||||
require_file "$ROOT/web/dist/index.html" "run: make web"
|
||||
# libkvm.so is the one runtime library that must come from the build: the copy
|
||||
# tracked in server/dl_lib/ exists for cgo link time and lags far behind. If it
|
||||
# were optional here, "make package" on its own would quietly ship the stale one.
|
||||
require_file "$ROOT/kvmapp/server/dl_lib/libkvm.so" "run: make vision"
|
||||
|
||||
require_riscv64 "$ROOT/server/NanoKVM-Server"
|
||||
require_riscv64 "$ROOT/kvmapp/kvm_system/kvm_system"
|
||||
require_riscv64 "$ROOT/kvmapp/server/dl_lib/libkvm.so"
|
||||
|
||||
echo "[INFO] staging nanokvm_$VERSION"
|
||||
# Clear the whole output directory: release.yml uploads build/release/nanokvm_*
|
||||
# by glob, and a leftover tarball from an earlier version would ride along.
|
||||
rm -rf "$OUT"
|
||||
mkdir -p "$STAGE"
|
||||
|
||||
# 1. Everything tracked under kvmapp/ (init scripts, picoclaw bundle, kernel
|
||||
# module, jpg_stream, kvm_stream) plus whatever the support build dropped in
|
||||
# (kvm_system, server/dl_lib). -a carries the exec bits over from git, which
|
||||
# only matters for reading the archive by hand: install.go chmods the whole
|
||||
# tree to 0755 on the device regardless.
|
||||
cp -a "$ROOT/kvmapp/." "$STAGE/"
|
||||
|
||||
# 2. Version marker. The updater reads /kvmapp/version to report the currently
|
||||
# installed version (version.go).
|
||||
printf '%s\n' "$VERSION" > "$STAGE/version"
|
||||
|
||||
# 3. Go server binary.
|
||||
mkdir -p "$STAGE/server"
|
||||
cp -a "$ROOT/server/NanoKVM-Server" "$STAGE/server/NanoKVM-Server"
|
||||
|
||||
# 4. Runtime shared libraries. "build kvm_vision add_to_kvmapp" copies its whole
|
||||
# dist/dl_lib into kvmapp/server/dl_lib, so freshly built libraries are
|
||||
# already staged; the tracked server/dl_lib/ backfills the rest. Never
|
||||
# clobber a fresh library with the tracked (link-time) copy.
|
||||
mkdir -p "$STAGE/server/dl_lib"
|
||||
for lib in "$ROOT"/server/dl_lib/*; do
|
||||
[ -e "$lib" ] || continue
|
||||
name="$(basename "$lib")"
|
||||
if [ ! -e "$STAGE/server/dl_lib/$name" ]; then
|
||||
cp -a "$lib" "$STAGE/server/dl_lib/$name"
|
||||
fi
|
||||
done
|
||||
|
||||
# The SDK is built from an unpinned MaixCDK checkout, so its dist could one
|
||||
# day add or rename a library (e.g. an soname bump leaving both .409 and
|
||||
# .410 behind) and we would ship a library set nobody reviewed. Require the
|
||||
# shipped names to be exactly the tracked set, and fail loudly otherwise.
|
||||
staged_libs=$(cd "$STAGE/server/dl_lib" && ls -1 | LC_ALL=C sort)
|
||||
tracked_libs=$(cd "$ROOT/server/dl_lib" && ls -1 | LC_ALL=C sort)
|
||||
if [ "$staged_libs" != "$tracked_libs" ]; then
|
||||
echo "[ERROR] shipped server/dl_lib does not match the tracked library set" >&2
|
||||
echo " unexpected (in package, not tracked):" >&2
|
||||
comm -23 <(printf '%s\n' "$staged_libs") <(printf '%s\n' "$tracked_libs") \
|
||||
| sed 's/^/ + /' >&2
|
||||
echo " absent (tracked, not in package):" >&2
|
||||
comm -13 <(printf '%s\n' "$staged_libs") <(printf '%s\n' "$tracked_libs") \
|
||||
| sed 's/^/ - /' >&2
|
||||
echo " if this change is intended, update server/dl_lib/ to match." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A library arrives either from the SDK dist (mode 755) or from the tracked
|
||||
# backfill (git stores them 644), so without this the archive would depend on
|
||||
# which path populated each file. 644 is what published releases have always
|
||||
# carried, and install.go chmods the installed tree to 0755 anyway.
|
||||
chmod 644 "$STAGE"/server/dl_lib/*
|
||||
|
||||
# 5. Frontend. router.go serves <dir of executable>/web.
|
||||
rm -rf "$STAGE/server/web"
|
||||
mkdir -p "$STAGE/server/web"
|
||||
cp -a "$ROOT/web/dist/." "$STAGE/server/web/"
|
||||
|
||||
# 6. EDID helper shipped under system/tool/ (prebuilt riscv64 binary in tools/).
|
||||
mkdir -p "$STAGE/system/tool"
|
||||
cp -a "$ROOT/tools/nanokvm_update_edid/nanokvm_update_edid" "$STAGE/system/tool/"
|
||||
cp -a "$ROOT/tools/nanokvm_update_edid/E21_NanoKVM.bin" "$STAGE/system/tool/"
|
||||
|
||||
# 7. Default runtime state read by common.GetScreen() on first boot.
|
||||
mkdir -p "$STAGE/kvm"
|
||||
printf '30\n' > "$STAGE/kvm/fps"
|
||||
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 '0\n' > "$STAGE/kvm/state"
|
||||
printf 'mjpeg\n' > "$STAGE/kvm/type"
|
||||
printf '0' > "$STAGE/kvm/res"
|
||||
|
||||
# 8. Directories the released package has always carried, kept so the layout
|
||||
# matches previous releases exactly (system_init.cpp probes inside them).
|
||||
mkdir -p "$STAGE/jpg_stream/dl_lib" "$STAGE/kvm_system/dl_lib"
|
||||
|
||||
# --- archive -----------------------------------------------------------------
|
||||
# Normalise owner and timestamps so the same source tree yields the same
|
||||
# tarball, which makes the published sha512 verifiable after the fact.
|
||||
SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git -C "$ROOT" log -1 --format=%ct 2>/dev/null || echo 0)}"
|
||||
|
||||
find "$STAGE" -exec touch -d "@$SOURCE_DATE_EPOCH" {} + 2>/dev/null \
|
||||
|| find "$STAGE" -exec touch -t "$(date -r "$SOURCE_DATE_EPOCH" +%Y%m%d%H%M.%S)" {} + 2>/dev/null \
|
||||
|| echo "[WARN] could not normalise timestamps"
|
||||
|
||||
echo "[INFO] creating $(basename "$TARBALL")"
|
||||
rm -f "$TARBALL"
|
||||
|
||||
# Read the version into a variable first: piping through head can hand tar a
|
||||
# SIGPIPE, and under "set -o pipefail" that would silently select the bsdtar
|
||||
# branch on a GNU system, quietly losing reproducibility.
|
||||
tar_version="$(tar --version 2>/dev/null || true)"
|
||||
|
||||
if [ "${tar_version#*GNU}" != "$tar_version" ]; then
|
||||
tar --format=gnu --sort=name \
|
||||
--owner=0 --group=0 --numeric-owner \
|
||||
--mtime="@$SOURCE_DATE_EPOCH" \
|
||||
-C "$OUT" -cf - "nanokvm_$VERSION" \
|
||||
| gzip -n -9 > "$TARBALL"
|
||||
else
|
||||
# bsdtar (macOS): no --sort/--mtime, so the archive is not byte-reproducible.
|
||||
echo "[WARN] GNU tar not found; archive will not be byte-reproducible"
|
||||
tar --uid 0 --gid 0 --uname '' --gname '' \
|
||||
-C "$OUT" -cf - "nanokvm_$VERSION" \
|
||||
| gzip -n -9 > "$TARBALL"
|
||||
fi
|
||||
|
||||
# --- manifest ----------------------------------------------------------------
|
||||
# update.go compares base64(raw sha512), not the hex digest.
|
||||
SHA512="$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)"
|
||||
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_BYTES,
|
||||
"size_bytes": $SIZE_BYTES,
|
||||
"unpacked_size_bytes": $UNPACKED_SIZE_BYTES
|
||||
}
|
||||
EOF
|
||||
|
||||
echo
|
||||
echo "[DONE] $TARBALL"
|
||||
echo "[DONE] $MANIFEST"
|
||||
cat "$MANIFEST"
|
||||
157
scripts/verify-release-assets.sh
Executable file
157
scripts/verify-release-assets.sh
Executable file
@@ -0,0 +1,157 @@
|
||||
#!/bin/bash
|
||||
# Verify the three assets that make up a NanoKVM GitHub release.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ASSET_DIR="${1:-}"
|
||||
VERSION="${2:-}"
|
||||
|
||||
if [ -z "$ASSET_DIR" ] || [ -z "$VERSION" ]; then
|
||||
echo "Usage: $0 <asset-directory> <version>" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "[ERROR] invalid version '$VERSION', expected MAJOR.MINOR.PATCH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARBALL_NAME="nanokvm_${VERSION}.tar.gz"
|
||||
TARBALL="$ASSET_DIR/$TARBALL_NAME"
|
||||
MANIFEST="$ASSET_DIR/latest.json"
|
||||
CHECKSUM="$ASSET_DIR/sha256.txt"
|
||||
|
||||
for path in "$TARBALL" "$MANIFEST" "$CHECKSUM"; do
|
||||
if [ ! -f "$path" ]; then
|
||||
echo "[ERROR] missing release asset: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
|
||||
if ! tar -tzf "$TARBALL" > "$ENTRY_LIST"; then
|
||||
echo "[ERROR] could not list release tarball" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! tar -tvzf "$TARBALL" > "$VERBOSE_LIST"; then
|
||||
echo "[ERROR] could not inspect release tarball entry types" >&2
|
||||
exit 1
|
||||
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"/*) ;;
|
||||
*)
|
||||
echo "[ERROR] archive entry is outside $PACKAGE_ROOT/: $entry" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "/$entry/" in
|
||||
*/../*|*/./*)
|
||||
echo "[ERROR] archive entry contains an unsafe path component: $entry" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done < "$ENTRY_LIST"
|
||||
if [ "$ENTRY_COUNT" -eq 0 ]; then
|
||||
echo "[ERROR] release tarball is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while IFS= read -r verbose_entry; do
|
||||
entry_type=${verbose_entry:0:1}
|
||||
case "$entry_type" in
|
||||
-|d) ;;
|
||||
*)
|
||||
echo "[ERROR] archive contains a link or special entry: $verbose_entry" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done < "$VERBOSE_LIST"
|
||||
|
||||
ARCHIVE_VERSION=$(tar -xOzf "$TARBALL" "$PACKAGE_ROOT/version" 2>/dev/null) || {
|
||||
echo "[ERROR] release tarball does not contain $PACKAGE_ROOT/version" >&2
|
||||
exit 1
|
||||
}
|
||||
if [ "$ARCHIVE_VERSION" != "$VERSION" ]; then
|
||||
echo "[ERROR] archive version '$ARCHIVE_VERSION' does not match '$VERSION'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SHA256=$(sha256sum "$TARBALL" | cut -d' ' -f1)
|
||||
if ! printf '%s %s\n' "$SHA256" "$TARBALL_NAME" | cmp -s - "$CHECKSUM"; then
|
||||
echo "[ERROR] sha256.txt does not match $TARBALL_NAME" >&2
|
||||
exit 1
|
||||
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
|
||||
echo "[ERROR] latest.json version '$MANIFEST_VERSION' does not match '$VERSION'" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$MANIFEST_NAME" != "$TARBALL_NAME" ]; then
|
||||
echo "[ERROR] latest.json name '$MANIFEST_NAME' does not match '$TARBALL_NAME'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTUAL_SIZE=$(wc -c < "$TARBALL" | tr -d ' ')
|
||||
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
|
||||
echo "[ERROR] latest.json sha512 does not match $TARBALL_NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[OK] verified NanoKVM release assets for $VERSION"
|
||||
43
scripts/verify-release-tag.sh
Executable file
43
scripts/verify-release-tag.sh
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
# Resolve an annotated numeric tag from origin and verify its commit is on main.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
TAG="${1:-}"
|
||||
EXPECTED_SHA="${2:-}"
|
||||
|
||||
if ! echo "$TAG" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "[ERROR] invalid tag '$TAG', expected MAJOR.MINOR.PATCH" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$EXPECTED_SHA" ] && ! echo "$EXPECTED_SHA" | grep -qE '^[0-9a-f]{40}$'; then
|
||||
echo "[ERROR] expected commit must be a full lowercase commit SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG_REF="refs/tags/$TAG"
|
||||
PEELED_REF="${TAG_REF}^{}"
|
||||
REMOTE_REFS=$(git ls-remote --tags origin "$TAG_REF" "$PEELED_REF")
|
||||
TAG_OBJECT=$(printf '%s\n' "$REMOTE_REFS" | awk -v ref="$TAG_REF" '$2 == ref { print $1 }')
|
||||
SOURCE_SHA=$(printf '%s\n' "$REMOTE_REFS" | awk -v ref="$PEELED_REF" '$2 == ref { print $1 }')
|
||||
|
||||
if ! echo "$TAG_OBJECT" | grep -qE '^[0-9a-f]{40}$'; then
|
||||
echo "[ERROR] tag '$TAG' does not exist on origin" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$SOURCE_SHA" | grep -qE '^[0-9a-f]{40}$'; then
|
||||
echo "[ERROR] tag '$TAG' is not an annotated tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$EXPECTED_SHA" ] && [ "$SOURCE_SHA" != "$EXPECTED_SHA" ]; then
|
||||
echo "[ERROR] tag '$TAG' moved from '$EXPECTED_SHA' to '$SOURCE_SHA'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch --no-tags origin main
|
||||
if ! git merge-base --is-ancestor "$SOURCE_SHA" FETCH_HEAD; then
|
||||
echo "[ERROR] tag '$TAG' points to a commit outside origin/main" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$SOURCE_SHA"
|
||||
@@ -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,31 @@ turn:
|
||||
turnCred: example_cred # The credential/password required for authorization to the TURN server
|
||||
```
|
||||
|
||||
## Web Users
|
||||
|
||||
NanoKVM uses two device-wide roles:
|
||||
|
||||
- `admin`: KVM access plus user, system, network, update, storage, terminal, script, MCP, and PicoClaw administration.
|
||||
- `user`: KVM video, keyboard, mouse, paste, power/reset, and Wake-on-LAN access.
|
||||
|
||||
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. Disabling, deleting,
|
||||
changing the role or password of a user invalidates that user's HTTP and real-time connections
|
||||
without affecting other users. Multiple users may watch and control the KVM concurrently; input
|
||||
uses the existing cooperative HID coordinator, so simultaneous input can interleave.
|
||||
Video mode, quality, resolution, and MJPEG frame-detection controls remain shared KVM
|
||||
operations; when several users adjust them concurrently, the latest change applies device-wide.
|
||||
|
||||
## Compile & Deploy
|
||||
|
||||
Note: Use Linux operating system (x86-64). 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).
|
||||
|
||||
@@ -65,7 +65,7 @@ turn:
|
||||
|
||||
## コンパイルとデプロイ
|
||||
|
||||
注意: Linux オペレーティングシステム (x86-64) を使用してください。このビルドプロセスは ARM、Windows、macOS では互換性がありません。
|
||||
注意: Linux オペレーティングシステム (x86-64) と Go 1.25 以降を使用してください。このビルドプロセスは ARM、Windows、macOS では互換性がありません。
|
||||
|
||||
1. ツールチェーンのインストール
|
||||
1. 以下のリンクからツールチェーンをダウンロードします: [ダウンロードリンク](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,9 +60,25 @@ turn:
|
||||
turnCred: example_cred # TURN 服务器授权连接时使用的凭据/密码
|
||||
```
|
||||
|
||||
## Web 多用户
|
||||
|
||||
NanoKVM 使用两种设备级角色:
|
||||
|
||||
- `admin`:除 KVM 操作外,还可管理用户、系统、网络、更新、存储、终端、脚本、MCP 和 PicoClaw。
|
||||
- `user`:可使用 KVM 视频、键盘、鼠标、粘贴、目标机电源/复位和网络唤醒。
|
||||
|
||||
管理员可在**设置 > 账户**中管理用户。账户数据继续保存在 `/etc/kvm/pwd`;服务会原地迁移旧版单账户
|
||||
JSON,并以 `0600` 权限原子写入多用户格式。沿用同一路径可保持长按 BOOT 键重置密码的现有行为。
|
||||
用户自助修改密码时必须验证当前密码;管理员可在账户管理中重置非设备所有者的密码。由于设备所有者
|
||||
密码还会同步至 Linux root 账户,因此只有设备所有者本人可以修改自己的密码。
|
||||
|
||||
所有登录会话都会校验当前账户状态。禁用、删除、修改角色或密码会立即撤销该用户的 HTTP 与实时连接,
|
||||
且不会影响其他用户。多个用户可同时观看和协作控制 KVM;输入沿用现有 HID 协调器,因此同时输入可能交错。
|
||||
视频模式、画质、分辨率和 MJPEG 帧检测仍属于共享 KVM 操作;多人同时调整时,以最后一次设备级修改为准。
|
||||
|
||||
## 编译部署
|
||||
|
||||
**注意:请使用 Linux 操作系统(x86-64)。该工具链无法在 ARM、Windows 或 macOS 下使用。**
|
||||
**注意:请使用 Linux 操作系统(x86-64)和 Go 1.25 或更高版本。该工具链无法在 ARM、Windows 或 macOS 下使用。**
|
||||
|
||||
1. 安装工具链
|
||||
1. 下载工具链:[下载地址](https://sophon-file.sophon.cn/sophon-prod-s3/drive/23/03/07/16/host-tools.tar.gz);
|
||||
|
||||
616
server/authn/store.go
Normal file
616
server/authn/store.go
Normal file
@@ -0,0 +1,616 @@
|
||||
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"`
|
||||
MustChangePassword bool `json:"mustChangePassword,omitempty"`
|
||||
SystemAccount bool `json:"systemAccount,omitempty"`
|
||||
}
|
||||
|
||||
type UserInfo struct {
|
||||
Username string `json:"username"`
|
||||
Role Role `json:"role"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SystemAccount bool `json:"systemAccount,omitempty"`
|
||||
}
|
||||
|
||||
type UserPatch struct {
|
||||
Role *Role
|
||||
Enabled *bool
|
||||
}
|
||||
|
||||
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 _, user := range db.Users {
|
||||
users = append(users, UserInfo{
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
Enabled: user.Enabled,
|
||||
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) 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")
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
return s.saveLocked(db)
|
||||
}
|
||||
|
||||
func (s *Store) Update(actor, username string, patch UserPatch) (*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
|
||||
}
|
||||
user := db.Users[index]
|
||||
|
||||
if patch.Role != nil && !IsValidRole(*patch.Role) {
|
||||
return nil, errors.New("invalid role")
|
||||
}
|
||||
if actor == username && user.Role == RoleAdmin &&
|
||||
((patch.Role != nil && *patch.Role != RoleAdmin) || (patch.Enabled != nil && !*patch.Enabled)) {
|
||||
return nil, ErrSelfModification
|
||||
}
|
||||
if user.SystemAccount &&
|
||||
((patch.Role != nil && *patch.Role != RoleAdmin) || (patch.Enabled != nil && !*patch.Enabled)) {
|
||||
return nil, ErrSystemAccount
|
||||
}
|
||||
|
||||
changed := 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
|
||||
}
|
||||
if !changed {
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
db.Users[index] = user
|
||||
if enabledAdminCount(db.Users) == 0 {
|
||||
return nil, ErrLastAdmin
|
||||
}
|
||||
db.Users[index].TokenVersion++
|
||||
if err = s.saveLocked(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return 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
|
||||
}
|
||||
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 {
|
||||
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,
|
||||
}},
|
||||
}
|
||||
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 _, 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
|
||||
}
|
||||
|
||||
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.
Binary file not shown.
@@ -1,17 +1,20 @@
|
||||
module NanoKVM-Server
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/gin-gonic/contrib v0.0.0-20240508051311-c1c6bf0061b0
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-playground/validator/v10 v10.20.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/jsonschema-go v0.4.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
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
|
||||
@@ -19,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
|
||||
)
|
||||
|
||||
@@ -45,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
|
||||
@@ -60,6 +63,8 @@ require (
|
||||
github.com/rs/cors v1.11.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
@@ -68,12 +73,13 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.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
|
||||
|
||||
@@ -34,11 +34,13 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -65,6 +67,8 @@ github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0 h
|
||||
github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0/go.mod h1:Eb5RMoo9kOQra/2uRiUTGP+LfNuM13Vqm7y7P34+KKo=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -119,6 +123,10 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
@@ -154,6 +162,8 @@ github.com/unrolled/secure v1.15.0 h1:q7x+pdp8jAHnbzxu6UheP8fRlG/rwYTb8TPuQ3rn9O
|
||||
github.com/unrolled/secure v1.15.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
@@ -167,13 +177,17 @@ golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjs
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -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/vm"
|
||||
"NanoKVM-Server/service/vm/jiggler"
|
||||
"NanoKVM-Server/utils"
|
||||
|
||||
@@ -38,12 +39,12 @@ func initialize() {
|
||||
_ = common.GetScreen()
|
||||
|
||||
// init HDMI
|
||||
vision := common.GetKvmVision()
|
||||
vision.SetHDMI(false)
|
||||
vm.DisableHdmiCapture()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
if !utils.IsHdmiDisabled() {
|
||||
vision.SetHDMI(true)
|
||||
vm.EnableHdmiCapture()
|
||||
}
|
||||
vm.SetHdmiViewerCount(0)
|
||||
|
||||
// run mouse jiggler
|
||||
jiggler.GetJiggler().Run()
|
||||
|
||||
@@ -1,68 +1,160 @@
|
||||
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
|
||||
}
|
||||
|
||||
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())
|
||||
c.Request = c.Request.WithContext(requestContext)
|
||||
unregister := activeSessions.register(principal.Username, cancel)
|
||||
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 {
|
||||
c.JSON(http.StatusForbidden, "forbidden")
|
||||
c.Abort()
|
||||
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}, 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}, token, true
|
||||
}
|
||||
|
||||
func abortUnauthorized(c *gin.Context) {
|
||||
@@ -70,37 +162,44 @@ func abortUnauthorized(c *gin.Context) {
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
func GenerateJWT(username string) (string, error) {
|
||||
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)
|
||||
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
|
||||
}
|
||||
}
|
||||
123
server/middleware/session.go
Normal file
123
server/middleware/session.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const SessionRevokedCloseCode = 4401
|
||||
|
||||
type sessionRegistry struct {
|
||||
mutex sync.Mutex
|
||||
nextID atomic.Uint64
|
||||
byUserID map[string]map[uint64]context.CancelFunc
|
||||
}
|
||||
|
||||
var activeSessions = &sessionRegistry{byUserID: make(map[string]map[uint64]context.CancelFunc)}
|
||||
|
||||
func (r *sessionRegistry) register(username string, cancel context.CancelFunc) func() {
|
||||
id := r.nextID.Add(1)
|
||||
r.mutex.Lock()
|
||||
if r.byUserID[username] == nil {
|
||||
r.byUserID[username] = make(map[uint64]context.CancelFunc)
|
||||
}
|
||||
r.byUserID[username][id] = cancel
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
func RevokeUserSessions(username string) {
|
||||
activeSessions.mutex.Lock()
|
||||
sessions := activeSessions.byUserID[username]
|
||||
delete(activeSessions.byUserID, username)
|
||||
activeSessions.mutex.Unlock()
|
||||
|
||||
for _, cancel := range sessions {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func WatchWebSocket(ctx context.Context, connection *websocket.Conn) func() {
|
||||
stopped := make(chan struct{})
|
||||
var stopOnce sync.Once
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = connection.WriteControl(
|
||||
websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(SessionRevokedCloseCode, "session expired or revoked"),
|
||||
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,38 @@ type LoginReq struct {
|
||||
Password string `validate:"required"`
|
||||
}
|
||||
|
||||
type LoginRsp struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type GetAccountRsp struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
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"`
|
||||
SystemAccount bool `json:"systemAccount,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"`
|
||||
}
|
||||
|
||||
type UpdateUserReq struct {
|
||||
Role *string `json:"role"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package proto
|
||||
|
||||
type DownloadImageReq struct {
|
||||
File string `json:"file" validate:"required"`
|
||||
SHA256Sum string `json:"sha256sum"`
|
||||
}
|
||||
|
||||
type ImageEnabledRsp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
@@ -4,6 +4,14 @@ type GetHidModeRsp struct {
|
||||
Mode string `json:"mode"` // normal or hid-only
|
||||
}
|
||||
|
||||
type GetKeyboardLedStatusRsp struct {
|
||||
NumLock bool `json:"numLock"`
|
||||
CapsLock bool `json:"capsLock"`
|
||||
ScrollLock bool `json:"scrollLock"`
|
||||
Known bool `json:"known"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type SetHidModeReq struct {
|
||||
Mode string `validate:"required"` // normal or hid-only
|
||||
}
|
||||
|
||||
12
server/proto/mcp.go
Normal file
12
server/proto/mcp.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package proto
|
||||
|
||||
type GetMCPConfigRsp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"apiKey"`
|
||||
ControlMode string `json:"controlMode"`
|
||||
Transitioning bool `json:"transitioning"`
|
||||
}
|
||||
|
||||
type SetMCPConfigReq struct {
|
||||
Enabled *bool `json:"enabled" form:"enabled" validate:"required"`
|
||||
}
|
||||
@@ -26,7 +26,7 @@ type GetWifiRsp struct {
|
||||
|
||||
type ConnectWifiReq struct {
|
||||
Ssid string `validate:"required"`
|
||||
Password string `valid:"required"`
|
||||
Password string `validate:"required"`
|
||||
}
|
||||
|
||||
type GetDNSRsp struct {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -98,7 +98,13 @@ type GetOLEDRsp struct {
|
||||
}
|
||||
|
||||
type GetGetHdmiStateRsp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Signal bool `json:"signal"`
|
||||
IdleTimeout int `json:"idleTimeout"`
|
||||
}
|
||||
|
||||
type SetHdmiIdleTimeoutReq struct {
|
||||
Minutes int `validate:"gte=0,lte=10080"`
|
||||
}
|
||||
|
||||
type GetSSHStateRsp struct {
|
||||
@@ -146,3 +152,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.RequireRole(authn.RoleAdmin))
|
||||
|
||||
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,14 @@ 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/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)
|
||||
}
|
||||
|
||||
163
server/router/control.go
Normal file
163
server/router/control.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/hid"
|
||||
"NanoKVM-Server/service/inputcontrol"
|
||||
"NanoKVM-Server/service/picoclaw"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type setAIControlModeRequest struct {
|
||||
Mode controlmode.Mode `json:"mode"`
|
||||
}
|
||||
|
||||
func controlRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *picoclaw.Service) {
|
||||
group := r.Group("/api/ai/control").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
)
|
||||
group.GET("/status", func(c *gin.Context) {
|
||||
status, err := control.Status()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": -1, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": status})
|
||||
})
|
||||
|
||||
group.PUT("/mode", func(c *gin.Context) {
|
||||
startedAt := time.Now()
|
||||
var req setAIControlModeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": -1, "message": "invalid arguments"})
|
||||
return
|
||||
}
|
||||
if !validAIControlMode(req.Mode) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": -1, "message": "invalid control mode"})
|
||||
return
|
||||
}
|
||||
|
||||
previousStatus, previousErr := control.Status()
|
||||
if previousErr != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"request_mode": string(req.Mode),
|
||||
"elapsed_ms": time.Since(startedAt).Milliseconds(),
|
||||
}).WithError(previousErr).Warn("AI control mode request failed before switch")
|
||||
c.JSON(http.StatusOK, gin.H{"code": -2, "message": previousErr.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
preempt := func() error {
|
||||
inputcontrol.GetCoordinator().CancelMCP()
|
||||
if req.Mode == controlmode.ModeMCP {
|
||||
return picoclawService.PreemptControlLeasesForMCP()
|
||||
}
|
||||
picoclawService.CancelActiveControlOperations()
|
||||
return nil
|
||||
}
|
||||
cleanup := func() error {
|
||||
return hid.ReleaseAllHIDStateBestEffort()
|
||||
}
|
||||
if req.Mode == controlmode.ModeMCP {
|
||||
cleanup = func() error {
|
||||
if err := picoclawService.StopRuntimeForMCP(); err != nil {
|
||||
return err
|
||||
}
|
||||
return hid.ReleaseAllHIDStateBestEffort()
|
||||
}
|
||||
}
|
||||
if err := control.SwitchWithCleanup(req.Mode, preempt, cleanup); err != nil {
|
||||
if req.Mode == controlmode.ModeOff {
|
||||
if status, statusErr := control.Status(); statusErr == nil && status.Mode == controlmode.ModeOff {
|
||||
picoclawService.CancelActiveControlOperations()
|
||||
picoclawService.PreserveRuntimeForChatOnly("control_release_chat_only")
|
||||
closedSessions := 0
|
||||
picoclawService.PublishControlModeChangedFrom(status, "ai_control_mode")
|
||||
log.WithFields(log.Fields{
|
||||
"request_mode": string(req.Mode),
|
||||
"previous_mode": string(previousStatus.Mode),
|
||||
"final_mode": string(status.Mode),
|
||||
"transitioning": status.Transitioning,
|
||||
"closed_sessions": closedSessions,
|
||||
"elapsed_ms": time.Since(startedAt).Milliseconds(),
|
||||
}).WithError(err).Warn("AI control mode request completed with cleanup warning")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": gin.H{
|
||||
"control": status,
|
||||
"runtime": picoclawService.RuntimeStatus(),
|
||||
"released": true,
|
||||
"closed_sessions": closedSessions,
|
||||
"cleanup_warning": err.Error(),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
log.WithFields(log.Fields{
|
||||
"request_mode": string(req.Mode),
|
||||
"previous_mode": string(previousStatus.Mode),
|
||||
"elapsed_ms": time.Since(startedAt).Milliseconds(),
|
||||
}).WithError(err).Warn("AI control mode request failed")
|
||||
c.JSON(http.StatusOK, gin.H{"code": -2, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
status, err := control.Status()
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"request_mode": string(req.Mode),
|
||||
"previous_mode": string(previousStatus.Mode),
|
||||
"elapsed_ms": time.Since(startedAt).Milliseconds(),
|
||||
}).WithError(err).Warn("AI control mode request failed after switch")
|
||||
c.JSON(http.StatusOK, gin.H{"code": -2, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
closedSessions := 0
|
||||
if req.Mode == controlmode.ModeOff {
|
||||
picoclawService.CancelActiveControlOperations()
|
||||
picoclawService.PreserveRuntimeForChatOnly("control_release_chat_only")
|
||||
}
|
||||
picoclawService.PublishControlModeChangedFrom(status, "ai_control_mode")
|
||||
log.WithFields(log.Fields{
|
||||
"request_mode": string(req.Mode),
|
||||
"previous_mode": string(previousStatus.Mode),
|
||||
"final_mode": string(status.Mode),
|
||||
"transitioning": status.Transitioning,
|
||||
"elapsed_ms": time.Since(startedAt).Milliseconds(),
|
||||
}).Info("AI control mode request completed")
|
||||
if req.Mode == controlmode.ModeOff {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": gin.H{
|
||||
"control": status,
|
||||
"runtime": picoclawService.RuntimeStatus(),
|
||||
"released": true,
|
||||
"closed_sessions": closedSessions,
|
||||
"cleanup_warning": "",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": status})
|
||||
})
|
||||
}
|
||||
|
||||
func validAIControlMode(mode controlmode.Mode) bool {
|
||||
switch mode {
|
||||
case controlmode.ModeOff, controlmode.ModeMCP, controlmode.ModePicoclaw:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/service/download"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -9,10 +10,11 @@ import (
|
||||
|
||||
func downloadRouter(r *gin.Engine) {
|
||||
service := download.NewService()
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
|
||||
|
||||
api.POST("/download/image", service.DownloadImage) // download image
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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.RequireRole(authn.RoleAdmin),
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
@@ -20,16 +21,21 @@ func hidRouter(r *gin.Engine) {
|
||||
|
||||
api.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
|
||||
api.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
|
||||
api.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
|
||||
api.GET("/hid/mode", service.GetHidMode) // get hid mode
|
||||
api.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
|
||||
admin := r.Group("/api").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
)
|
||||
admin.POST("/hid/mode", service.SetHidMode) // set hid mode
|
||||
admin.POST("/hid/reset", service.ResetHid) // reset hid
|
||||
admin.POST("/hid/shortcut", service.AddShortcut)
|
||||
admin.DELETE("/hid/shortcut", service.DeleteShortcut)
|
||||
admin.POST("/hid/shortcut/leader-key", service.SetLeaderKey)
|
||||
|
||||
localAPI.POST("/usb/recover", service.RecoverUSB)
|
||||
}
|
||||
|
||||
38
server/router/mcp.go
Normal file
38
server/router/mcp.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/hid"
|
||||
mcpservice "NanoKVM-Server/service/mcp"
|
||||
"NanoKVM-Server/service/mcp/capture/kvm"
|
||||
"NanoKVM-Server/service/picoclaw"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func mcpRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *picoclaw.Service) {
|
||||
service := mcpservice.NewServiceWithPreempt(
|
||||
control,
|
||||
picoclawService.PreemptControlLeasesForMCP,
|
||||
picoclawService.StopRuntimeForMCP,
|
||||
hid.ReleaseAllHIDStateBestEffort,
|
||||
func(status controlmode.Status) {
|
||||
picoclawService.PublishControlModeChangedFrom(status, "mcp_config")
|
||||
},
|
||||
)
|
||||
management := r.Group("/api/mcp").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
)
|
||||
management.GET("/config", service.GetConfig)
|
||||
management.POST("/config", service.SetConfig)
|
||||
management.POST("/key/regenerate", service.RegenerateAPIKey)
|
||||
|
||||
handler := mcpservice.NewMCPHandler(control, kvmcapture.New())
|
||||
handler = http.NewCrossOriginProtection().Handler(handler)
|
||||
r.Any("/api/mcp", mcpservice.APIKeyMiddleware(control), gin.WrapH(handler))
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package router
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/network"
|
||||
)
|
||||
@@ -15,15 +16,19 @@ func networkRouter(r *gin.Engine) {
|
||||
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
|
||||
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
|
||||
api.POST("/network/wol", service.WakeOnLAN) // wake on lan
|
||||
api.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
|
||||
admin := r.Group("/api").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
)
|
||||
admin.DELETE("/network/wol/mac", service.DeleteMac) // delete mac
|
||||
admin.POST("/network/wol/mac/name", service.SetMacName) // set mac name
|
||||
|
||||
api.GET("/network/dns", service.GetDNS) // get DNS configuration
|
||||
api.POST("/network/dns", service.SetDNS) // set DNS configuration
|
||||
admin.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
|
||||
admin.POST("/network/wifi/connect", service.ConnectWifi) // connect Wi-Fi
|
||||
admin.POST("/network/wifi/disconnect", service.DisconnectWifi) // disconnect Wi-Fi
|
||||
admin.GET("/network/dns", service.GetDNS) // get DNS configuration
|
||||
admin.POST("/network/dns", service.SetDNS) // set DNS configuration
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package router
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/picoclaw"
|
||||
)
|
||||
@@ -38,9 +39,11 @@ func PicoclawLoopbackHTTPAllowedPaths() []string {
|
||||
return append([]string(nil), picoclawLoopbackHTTPAllowedPaths...)
|
||||
}
|
||||
|
||||
func picoclawRouter(r *gin.Engine) {
|
||||
service := picoclaw.NewService()
|
||||
frontendAPI := r.Group(picoclawBasePath).Use(middleware.CheckToken())
|
||||
func picoclawRouter(r *gin.Engine, service *picoclaw.Service) {
|
||||
frontendAPI := r.Group(picoclawBasePath).Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
)
|
||||
localAPI := r.Group(picoclawBasePath).Use(middleware.CheckLoopbackInternalToken())
|
||||
|
||||
localAPI.GET(picoclawScreenshotPath, service.Screenshot)
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/picoclaw"
|
||||
|
||||
"github.com/gin-gonic/contrib/static"
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -29,6 +32,9 @@ func web(r *gin.Engine) {
|
||||
}
|
||||
|
||||
func server(r *gin.Engine) {
|
||||
control := controlmode.GetManager()
|
||||
picoclawService := picoclaw.NewService(control)
|
||||
|
||||
authRouter(r)
|
||||
applicationRouter(r)
|
||||
vmRouter(r)
|
||||
@@ -36,7 +42,9 @@ func server(r *gin.Engine) {
|
||||
storageRouter(r)
|
||||
networkRouter(r)
|
||||
hidRouter(r)
|
||||
picoclawRouter(r)
|
||||
controlRouter(r, control, picoclawService)
|
||||
mcpRouter(r, control, picoclawService)
|
||||
picoclawRouter(r, picoclawService)
|
||||
wsRouter(r)
|
||||
downloadRouter(r)
|
||||
extensionsRouter(r)
|
||||
|
||||
@@ -3,13 +3,14 @@ package router
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/storage"
|
||||
)
|
||||
|
||||
func storageRouter(r *gin.Engine) {
|
||||
service := storage.NewService()
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
|
||||
|
||||
api.GET("/storage/image", service.GetImages) // get image list
|
||||
api.GET("/storage/image/mounted", service.GetMountedImage) // get mounted image
|
||||
|
||||
@@ -3,6 +3,7 @@ package router
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/vm"
|
||||
)
|
||||
@@ -11,6 +12,10 @@ func vmRouter(r *gin.Engine) {
|
||||
service := vm.NewService()
|
||||
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
admin := r.Group("/api").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
)
|
||||
|
||||
api.GET("/vm/info", service.GetInfo) // get device information
|
||||
api.GET("/vm/hardware", service.GetHardware) // get hardware version
|
||||
@@ -19,54 +24,59 @@ func vmRouter(r *gin.Engine) {
|
||||
api.GET("/vm/gpio", service.GetGpio) // get gpio
|
||||
api.POST("/vm/screen", service.SetScreen) // update screen
|
||||
|
||||
api.GET("/vm/terminal", service.Terminal) // web terminal
|
||||
api.GET("/vm/input-region", service.GetInputRegion)
|
||||
api.POST("/vm/input-region", service.SetInputRegion)
|
||||
api.GET("/vm/input-resolution", service.GetInputResolution)
|
||||
|
||||
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
|
||||
admin.GET("/vm/terminal", service.Terminal) // web terminal
|
||||
|
||||
api.GET("/vm/device/virtual", service.GetVirtualDevice) // get virtual device
|
||||
api.POST("/vm/device/virtual", service.UpdateVirtualDevice) // update virtual device
|
||||
admin.GET("/vm/script", service.GetScripts) // get script
|
||||
admin.POST("/vm/script/upload", service.UploadScript) // upload script
|
||||
admin.POST("/vm/script/run", service.RunScript) // run script
|
||||
admin.DELETE("/vm/script", service.DeleteScript) // delete script
|
||||
|
||||
api.GET("/vm/memory/limit", service.GetMemoryLimit) // get memory limit
|
||||
api.POST("/vm/memory/limit", service.SetMemoryLimit) // set memory limit
|
||||
admin.GET("/vm/device/virtual", service.GetVirtualDevice) // get virtual device
|
||||
admin.POST("/vm/device/virtual", service.UpdateVirtualDevice) // update virtual device
|
||||
|
||||
api.GET("/vm/oled", service.GetOLED) // get OLED configuration
|
||||
api.POST("/vm/oled", service.SetOLED) // set OLED configuration
|
||||
admin.GET("/vm/memory/limit", service.GetMemoryLimit) // get memory limit
|
||||
admin.POST("/vm/memory/limit", service.SetMemoryLimit) // set memory limit
|
||||
|
||||
admin.GET("/vm/oled", service.GetOLED) // get OLED configuration
|
||||
admin.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.GET("/vm/hdmi", service.GetHdmiState) // get HDMI state
|
||||
api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi
|
||||
admin.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi
|
||||
admin.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi
|
||||
admin.POST("/vm/hdmi/timeout", service.SetHdmiIdleTimeout)
|
||||
|
||||
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
|
||||
admin.GET("/vm/ssh", service.GetSSHState) // get SSH state
|
||||
admin.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH
|
||||
admin.POST("/vm/ssh/disable", service.DisableSSH) // disable SSH
|
||||
|
||||
api.GET("/vm/swap", service.GetSwap) // get swap file size
|
||||
api.POST("/vm/swap", service.SetSwap) // set swap file size
|
||||
admin.GET("/vm/swap", service.GetSwap) // get swap file size
|
||||
admin.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
|
||||
admin.GET("/vm/mouse-jiggler", service.GetMouseJiggler) // get mouse jiggler
|
||||
admin.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
|
||||
api.GET("/vm/hostname", service.GetHostname) // Get Hostname
|
||||
admin.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
|
||||
api.GET("/vm/web-title", service.GetWebTitle) // Get web title
|
||||
admin.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
|
||||
admin.GET("/vm/mdns", service.GetMdnsState) // get mDNS state
|
||||
admin.POST("/vm/mdns/enable", service.EnableMdns) // enable mDNS
|
||||
admin.POST("/vm/mdns/disable", service.DisableMdns) // disable mDNS
|
||||
|
||||
api.POST("/vm/tls", service.SetTls) // enable/disable TLS
|
||||
admin.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
|
||||
admin.GET("/vm/autostart", service.GetAutostart) // get autostart list
|
||||
admin.GET("/vm/autostart/:name", service.GetAutostartContent) // get autostart content
|
||||
admin.DELETE("/vm/autostart/:name", service.DeleteAutostart) // delete autostart script
|
||||
admin.POST("/vm/autostart/:name", service.UploadAutostart) // upload autostart script
|
||||
|
||||
api.POST("/vm/system/reboot", service.Reboot) // reboot system
|
||||
admin.POST("/vm/system/reboot", service.Reboot) // reboot system
|
||||
}
|
||||
|
||||
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,21 +37,43 @@ 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)
|
||||
defer func() {
|
||||
_ = os.RemoveAll(CacheDir)
|
||||
}()
|
||||
|
||||
if err := checkDownloadInProgress(); err != nil {
|
||||
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() {
|
||||
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
|
||||
}
|
||||
@@ -61,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
|
||||
}
|
||||
@@ -75,23 +121,71 @@ func offlineUpdate(c *gin.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkDownloadInProgress() error {
|
||||
if _, err := os.Stat(sentinelPath); err == nil {
|
||||
log.Debug("Download in progress")
|
||||
return fmt.Errorf("download already in progress")
|
||||
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 {
|
||||
if err := os.WriteFile(sentinelPath, []byte("downloading"), sentinelPermission); err != nil {
|
||||
file, err := os.OpenFile(
|
||||
sentinelPath,
|
||||
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
||||
sentinelPermission,
|
||||
)
|
||||
if err != nil {
|
||||
if os.IsExist(err) {
|
||||
return fmt.Errorf("download already in progress")
|
||||
}
|
||||
log.Errorf("Failed to create sentinel file: %v", err)
|
||||
return fmt.Errorf("failed to create sentinel file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := file.WriteString("downloading"); err != nil {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(sentinelPath)
|
||||
return fmt.Errorf("failed to initialize sentinel file: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
_ = os.Remove(sentinelPath)
|
||||
return fmt.Errorf("failed to close sentinel file: %w", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -106,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
|
||||
}
|
||||
@@ -120,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")
|
||||
@@ -130,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
|
||||
}
|
||||
@@ -167,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,98 @@ 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),
|
||||
})
|
||||
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
|
||||
}
|
||||
}
|
||||
127
server/service/auth/users.go
Normal file
127
server/service/auth/users.go
Normal file
@@ -0,0 +1,127 @@
|
||||
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")
|
||||
|
||||
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,
|
||||
SystemAccount: user.SystemAccount,
|
||||
})
|
||||
}
|
||||
rsp.OkRspWithData(c, &proto.ListUsersRsp{Users: result})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if err = authn.DefaultStore.Create(req.Username, password, authn.Role(req.Role)); err != nil {
|
||||
rsp.ErrRsp(c, -3, 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) {
|
||||
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
|
||||
}
|
||||
username := c.Param("username")
|
||||
if _, err := authn.DefaultStore.Update(principal.Username, username, patch); err != nil {
|
||||
rsp.ErrRsp(c, -2, err.Error())
|
||||
return
|
||||
}
|
||||
middleware.RevokeUserSessions(username)
|
||||
rsp.OkRsp(c)
|
||||
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")
|
||||
if err := authn.DefaultStore.Delete(principal.Username, username); err != nil {
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
middleware.RevokeUserSessions(username)
|
||||
rsp.OkRsp(c)
|
||||
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 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
|
||||
}
|
||||
97
server/service/controlmode/activity.go
Normal file
97
server/service/controlmode/activity.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package controlmode
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrActivityWaitTimeout = errors.New("timed out waiting for active control operations")
|
||||
|
||||
// activityGate allows normal control operations to share the current mode while
|
||||
// giving mode transitions exclusive access. Unlike sync.RWMutex, exclusive
|
||||
// acquisition is bounded so an abandoned client cannot leave the manager stuck
|
||||
// in the transitioning state forever.
|
||||
type activityGate struct {
|
||||
mu sync.Mutex
|
||||
active int
|
||||
exclusive bool
|
||||
changed chan struct{}
|
||||
}
|
||||
|
||||
func (g *activityGate) acquireShared() func() {
|
||||
for {
|
||||
g.mu.Lock()
|
||||
if !g.exclusive {
|
||||
g.active++
|
||||
g.mu.Unlock()
|
||||
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
g.mu.Lock()
|
||||
if g.active > 0 {
|
||||
g.active--
|
||||
}
|
||||
g.signalLocked()
|
||||
g.mu.Unlock()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
changed := g.changedLocked()
|
||||
g.mu.Unlock()
|
||||
<-changed
|
||||
}
|
||||
}
|
||||
|
||||
func (g *activityGate) acquireExclusive(timeout time.Duration) (func(), error) {
|
||||
if timeout <= 0 {
|
||||
return nil, ErrActivityWaitTimeout
|
||||
}
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
g.mu.Lock()
|
||||
if !g.exclusive && g.active == 0 {
|
||||
g.exclusive = true
|
||||
g.mu.Unlock()
|
||||
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
g.mu.Lock()
|
||||
g.exclusive = false
|
||||
g.signalLocked()
|
||||
g.mu.Unlock()
|
||||
})
|
||||
}, nil
|
||||
}
|
||||
|
||||
changed := g.changedLocked()
|
||||
g.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-changed:
|
||||
case <-timer.C:
|
||||
return nil, ErrActivityWaitTimeout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *activityGate) changedLocked() <-chan struct{} {
|
||||
if g.changed == nil {
|
||||
g.changed = make(chan struct{})
|
||||
}
|
||||
return g.changed
|
||||
}
|
||||
|
||||
func (g *activityGate) signalLocked() {
|
||||
if g.changed == nil {
|
||||
return
|
||||
}
|
||||
close(g.changed)
|
||||
g.changed = make(chan struct{})
|
||||
}
|
||||
555
server/service/controlmode/manager.go
Normal file
555
server/service/controlmode/manager.go
Normal file
@@ -0,0 +1,555 @@
|
||||
package controlmode
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const ModeFile = "/etc/kvm/ai-control.mode"
|
||||
|
||||
const defaultActivityWaitTimeout = 30 * time.Second
|
||||
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeOff Mode = "off"
|
||||
ModeMCP Mode = "mcp"
|
||||
ModePicoclaw Mode = "picoclaw"
|
||||
)
|
||||
|
||||
var ErrModeConflict = errors.New("AI control mode conflict")
|
||||
|
||||
type Status struct {
|
||||
Mode Mode `json:"mode"`
|
||||
Transitioning bool `json:"transitioning"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
ChangedAt time.Time `json:"changed_at,omitempty"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
transitionMu sync.Mutex
|
||||
activity activityGate
|
||||
mu sync.Mutex
|
||||
path string
|
||||
defaultMode Mode
|
||||
activityWaitTimeout time.Duration
|
||||
loaded bool
|
||||
mode Mode
|
||||
modeFileExists bool
|
||||
modeFileSize int64
|
||||
modeFileModTime time.Time
|
||||
transitioning bool
|
||||
lastError string
|
||||
changedAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
defaultManagerOnce sync.Once
|
||||
defaultManager *Manager
|
||||
)
|
||||
|
||||
func GetManager() *Manager {
|
||||
defaultManagerOnce.Do(func() {
|
||||
defaultManager = NewManager(ModeFile, ModePicoclaw)
|
||||
})
|
||||
return defaultManager
|
||||
}
|
||||
|
||||
func NewManager(path string, defaultMode Mode) *Manager {
|
||||
if !validMode(defaultMode) {
|
||||
defaultMode = ModeOff
|
||||
}
|
||||
return &Manager{
|
||||
path: path,
|
||||
defaultMode: defaultMode,
|
||||
activityWaitTimeout: defaultActivityWaitTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Status() (Status, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if err := m.loadLocked(); err != nil {
|
||||
return Status{Mode: ModeOff, Transitioning: m.transitioning, LastError: err.Error(), ChangedAt: m.changedAt}, err
|
||||
}
|
||||
return m.statusLocked(), nil
|
||||
}
|
||||
|
||||
func (m *Manager) Current() Mode {
|
||||
status, err := m.Status()
|
||||
if err != nil {
|
||||
return ModeOff
|
||||
}
|
||||
return status.Mode
|
||||
}
|
||||
|
||||
func (m *Manager) RequireWrite(expected Mode) error {
|
||||
status, err := m.Status()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status.Transitioning || status.Mode != expected {
|
||||
return fmt.Errorf("%w: current=%s expected=%s", ErrModeConflict, status.Mode, expected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Require(expected Mode) error {
|
||||
return m.RequireWrite(expected)
|
||||
}
|
||||
|
||||
func (m *Manager) RequireMode(expected Mode) error {
|
||||
status, err := m.Status()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status.Mode != expected {
|
||||
return fmt.Errorf("%w: current=%s expected=%s", ErrModeConflict, status.Mode, expected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) AcquireWrite(expected Mode) (func(), error) {
|
||||
if err := m.RequireWrite(expected); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
release := m.activity.acquireShared()
|
||||
if err := m.RequireWrite(expected); err != nil {
|
||||
release()
|
||||
return nil, err
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Acquire(expected Mode) (func(), error) {
|
||||
return m.AcquireWrite(expected)
|
||||
}
|
||||
|
||||
// AcquireStable holds the current control mode stable while a non-AI input
|
||||
// operation is in flight. Mode transitions take the exclusive side of the
|
||||
// activity gate, so they wait until the returned release function is called.
|
||||
func (m *Manager) AcquireStable() (Status, func(), error) {
|
||||
status, err := m.Status()
|
||||
if err != nil {
|
||||
return Status{Mode: ModeOff}, nil, err
|
||||
}
|
||||
if status.Transitioning {
|
||||
return status, nil, fmt.Errorf("%w: control mode is transitioning", ErrModeConflict)
|
||||
}
|
||||
|
||||
release := m.activity.acquireShared()
|
||||
status, err = m.Status()
|
||||
if err != nil {
|
||||
release()
|
||||
return Status{Mode: ModeOff}, nil, err
|
||||
}
|
||||
if status.Transitioning {
|
||||
release()
|
||||
return status, nil, fmt.Errorf("%w: control mode is transitioning", ErrModeConflict)
|
||||
}
|
||||
|
||||
return status, release, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Switch(next Mode, preempt func() error) error {
|
||||
return m.SwitchWithCleanup(next, preempt, nil)
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchWithCleanup(next Mode, preempt func() error, cleanup func() error) error {
|
||||
startedAt := time.Now()
|
||||
timings := log.Fields{
|
||||
"to": string(next),
|
||||
}
|
||||
|
||||
m.transitionMu.Lock()
|
||||
defer m.transitionMu.Unlock()
|
||||
|
||||
stageStartedAt := time.Now()
|
||||
status, err := m.Status()
|
||||
timings["load_status_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
if err != nil {
|
||||
logModeSwitchFailure("AI control mode switch failed while loading status", timings, err, startedAt)
|
||||
return err
|
||||
}
|
||||
timings["from"] = string(status.Mode)
|
||||
if !validMode(next) {
|
||||
err := fmt.Errorf("invalid AI control mode %q", next)
|
||||
logModeSwitchFailure("AI control mode switch rejected invalid target", timings, err, startedAt)
|
||||
return err
|
||||
}
|
||||
if status.Mode == next {
|
||||
timings["total_ms"] = elapsedMilliseconds(startedAt)
|
||||
log.WithFields(timings).Debug("AI control mode switch skipped")
|
||||
return nil
|
||||
}
|
||||
|
||||
m.setTransitioning(true)
|
||||
defer m.setTransitioning(false)
|
||||
if preempt != nil {
|
||||
stageStartedAt = time.Now()
|
||||
if err := callModeTransitionHook(preempt); err != nil {
|
||||
timings["preempt_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
m.setLastError(err)
|
||||
logModeSwitchFailure("AI control mode switch preempt failed", timings, err, startedAt)
|
||||
return err
|
||||
}
|
||||
timings["preempt_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
}
|
||||
|
||||
stageStartedAt = time.Now()
|
||||
releaseActivity, err := m.acquireExclusiveActivity()
|
||||
timings["activity_wait_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
if err != nil {
|
||||
m.setLastError(err)
|
||||
logModeSwitchFailure("AI control mode switch activity wait failed", timings, err, startedAt)
|
||||
return err
|
||||
}
|
||||
defer releaseActivity()
|
||||
|
||||
if cleanup != nil {
|
||||
stageStartedAt = time.Now()
|
||||
if err := callModeTransitionHook(cleanup); err != nil {
|
||||
timings["cleanup_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
if rollbackErr := m.save(ModeOff); rollbackErr != nil {
|
||||
err = errors.Join(err, rollbackErr)
|
||||
}
|
||||
m.setLastError(err)
|
||||
logModeSwitchFailure("AI control mode switch cleanup failed", timings, err, startedAt)
|
||||
return err
|
||||
}
|
||||
timings["cleanup_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
}
|
||||
|
||||
stageStartedAt = time.Now()
|
||||
if err := m.save(next); err != nil {
|
||||
timings["save_mode_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
m.setLastError(err)
|
||||
logModeSwitchFailure("AI control mode switch save failed", timings, err, startedAt)
|
||||
return err
|
||||
}
|
||||
timings["save_mode_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
m.setLastError(nil)
|
||||
timings["total_ms"] = elapsedMilliseconds(startedAt)
|
||||
log.WithFields(timings).Info("AI control mode switch completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchIf(expected Mode, next Mode, preempt func() error) (bool, error) {
|
||||
return m.SwitchIfWithCleanup(expected, next, preempt, nil)
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchIfWithCleanup(expected Mode, next Mode, preempt func() error, cleanup func() error) (bool, error) {
|
||||
startedAt := time.Now()
|
||||
timings := log.Fields{
|
||||
"expected": string(expected),
|
||||
"to": string(next),
|
||||
}
|
||||
|
||||
if !validMode(expected) || !validMode(next) {
|
||||
err := fmt.Errorf("invalid AI control mode transition %q -> %q", expected, next)
|
||||
logModeSwitchFailure("conditional AI control mode switch rejected invalid transition", timings, err, startedAt)
|
||||
return false, err
|
||||
}
|
||||
|
||||
m.transitionMu.Lock()
|
||||
defer m.transitionMu.Unlock()
|
||||
|
||||
stageStartedAt := time.Now()
|
||||
status, err := m.Status()
|
||||
timings["load_status_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
if err != nil {
|
||||
logModeSwitchFailure("conditional AI control mode switch failed while loading status", timings, err, startedAt)
|
||||
return false, err
|
||||
}
|
||||
timings["from"] = string(status.Mode)
|
||||
if status.Mode != expected {
|
||||
timings["matched"] = false
|
||||
timings["total_ms"] = elapsedMilliseconds(startedAt)
|
||||
log.WithFields(timings).Debug("conditional AI control mode switch skipped")
|
||||
return false, nil
|
||||
}
|
||||
if status.Mode == next {
|
||||
timings["matched"] = true
|
||||
timings["total_ms"] = elapsedMilliseconds(startedAt)
|
||||
log.WithFields(timings).Debug("conditional AI control mode switch already satisfied")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
m.setTransitioning(true)
|
||||
defer m.setTransitioning(false)
|
||||
if preempt != nil {
|
||||
stageStartedAt = time.Now()
|
||||
if err := callModeTransitionHook(preempt); err != nil {
|
||||
timings["preempt_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
m.setLastError(err)
|
||||
logModeSwitchFailure("conditional AI control mode switch preempt failed", timings, err, startedAt)
|
||||
return false, err
|
||||
}
|
||||
timings["preempt_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
}
|
||||
|
||||
stageStartedAt = time.Now()
|
||||
releaseActivity, err := m.acquireExclusiveActivity()
|
||||
timings["activity_wait_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
if err != nil {
|
||||
m.setLastError(err)
|
||||
logModeSwitchFailure("conditional AI control mode switch activity wait failed", timings, err, startedAt)
|
||||
return false, err
|
||||
}
|
||||
defer releaseActivity()
|
||||
|
||||
if cleanup != nil {
|
||||
stageStartedAt = time.Now()
|
||||
if err := callModeTransitionHook(cleanup); err != nil {
|
||||
timings["cleanup_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
if rollbackErr := m.save(ModeOff); rollbackErr != nil {
|
||||
err = errors.Join(err, rollbackErr)
|
||||
}
|
||||
m.setLastError(err)
|
||||
logModeSwitchFailure("conditional AI control mode switch cleanup failed", timings, err, startedAt)
|
||||
return false, err
|
||||
}
|
||||
timings["cleanup_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
}
|
||||
|
||||
stageStartedAt = time.Now()
|
||||
if err := m.save(next); err != nil {
|
||||
timings["save_mode_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
m.setLastError(err)
|
||||
logModeSwitchFailure("conditional AI control mode switch save failed", timings, err, startedAt)
|
||||
return false, err
|
||||
}
|
||||
timings["save_mode_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
m.setLastError(nil)
|
||||
timings["matched"] = true
|
||||
timings["total_ms"] = elapsedMilliseconds(startedAt)
|
||||
log.WithFields(timings).Info("conditional AI control mode switch completed")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchToMCP(preempt func() error) error {
|
||||
return m.Switch(ModeMCP, preempt)
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchToMCPWithPreempt(preempt func() error) error {
|
||||
return m.Switch(ModeMCP, preempt)
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchToPicoclaw(preempt func() error) error {
|
||||
return m.Switch(ModePicoclaw, preempt)
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchToPicoclawWithPreempt(preempt func() error) error {
|
||||
return m.Switch(ModePicoclaw, preempt)
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchOff(preempt func() error) error {
|
||||
return m.Switch(ModeOff, preempt)
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchOffIf(expected Mode, preempt func() error) (bool, error) {
|
||||
return m.SwitchIf(expected, ModeOff, preempt)
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchOffIfWithPreempt(expected Mode, preempt func() error) (bool, error) {
|
||||
return m.SwitchIf(expected, ModeOff, preempt)
|
||||
}
|
||||
|
||||
func callModeTransitionHook(hook func() error) (err error) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = fmt.Errorf("panic: %v", recovered)
|
||||
}
|
||||
}()
|
||||
return hook()
|
||||
}
|
||||
|
||||
func (m *Manager) setTransitioning(transitioning bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.transitioning = transitioning
|
||||
}
|
||||
|
||||
func (m *Manager) setLastError(err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if err == nil {
|
||||
m.lastError = ""
|
||||
return
|
||||
}
|
||||
m.lastError = err.Error()
|
||||
}
|
||||
|
||||
func (m *Manager) statusLocked() Status {
|
||||
return Status{
|
||||
Mode: m.mode,
|
||||
Transitioning: m.transitioning,
|
||||
LastError: m.lastError,
|
||||
ChangedAt: m.changedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) acquireExclusiveActivity() (func(), error) {
|
||||
timeout := m.activityWaitTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultActivityWaitTimeout
|
||||
}
|
||||
|
||||
release, err := m.activity.acquireExclusive(timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wait for active control operations: %w", err)
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func (m *Manager) save(mode Mode) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.saveLocked(mode)
|
||||
}
|
||||
|
||||
func (m *Manager) loadLocked() error {
|
||||
if m.loaded && m.transitioning {
|
||||
return nil
|
||||
}
|
||||
|
||||
info, statErr := os.Stat(m.path)
|
||||
if m.loaded && statErr == nil && m.modeFileExists && info.Size() == m.modeFileSize && info.ModTime().Equal(m.modeFileModTime) {
|
||||
return nil
|
||||
}
|
||||
if m.loaded && errors.Is(statErr, os.ErrNotExist) && !m.modeFileExists {
|
||||
return nil
|
||||
}
|
||||
if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
|
||||
return fmt.Errorf("stat AI control mode: %w", statErr)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(m.path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
m.mode = m.defaultMode
|
||||
m.loaded = true
|
||||
m.modeFileExists = false
|
||||
m.modeFileSize = 0
|
||||
m.modeFileModTime = time.Time{}
|
||||
if m.changedAt.IsZero() {
|
||||
m.changedAt = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read AI control mode: %w", err)
|
||||
}
|
||||
|
||||
mode := Mode(strings.TrimSpace(string(data)))
|
||||
if !validMode(mode) {
|
||||
m.mode = ModeOff
|
||||
m.loaded = true
|
||||
m.lastError = fmt.Sprintf("invalid AI control mode %q", strings.TrimSpace(string(data)))
|
||||
m.cacheModeFileInfoLocked(info, data)
|
||||
return nil
|
||||
}
|
||||
m.mode = mode
|
||||
m.loaded = true
|
||||
m.lastError = ""
|
||||
m.cacheModeFileInfoLocked(info, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) saveLocked(mode Mode) error {
|
||||
if !validMode(mode) {
|
||||
return fmt.Errorf("invalid AI control mode %q", mode)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(m.path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create AI control mode directory: %w", err)
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(dir, ".ai-control.mode.*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary AI control mode: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpPath) }()
|
||||
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("set AI control mode permissions: %w", err)
|
||||
}
|
||||
if _, err := tmp.WriteString(string(mode) + "\n"); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write AI control mode: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("sync AI control mode: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close AI control mode: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, m.path); err != nil {
|
||||
return fmt.Errorf("replace AI control mode: %w", err)
|
||||
}
|
||||
m.mode = mode
|
||||
m.loaded = true
|
||||
if info, err := os.Stat(m.path); err == nil {
|
||||
m.cacheModeFileInfoLocked(info, []byte(string(mode)+"\n"))
|
||||
} else {
|
||||
m.modeFileExists = true
|
||||
m.modeFileSize = int64(len(string(mode) + "\n"))
|
||||
m.modeFileModTime = time.Now()
|
||||
}
|
||||
m.changedAt = time.Now()
|
||||
|
||||
directory, err := os.Open(dir)
|
||||
if err == nil {
|
||||
if syncErr := directory.Sync(); syncErr != nil {
|
||||
_ = directory.Close()
|
||||
return fmt.Errorf("sync AI control mode directory: %w", syncErr)
|
||||
}
|
||||
_ = directory.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) cacheModeFileInfoLocked(info os.FileInfo, data []byte) {
|
||||
m.modeFileExists = true
|
||||
if info != nil {
|
||||
m.modeFileSize = info.Size()
|
||||
m.modeFileModTime = info.ModTime()
|
||||
if m.changedAt.IsZero() || info.ModTime().After(m.changedAt) {
|
||||
m.changedAt = info.ModTime()
|
||||
}
|
||||
return
|
||||
}
|
||||
m.modeFileSize = int64(len(data))
|
||||
m.modeFileModTime = time.Now()
|
||||
m.changedAt = m.modeFileModTime
|
||||
}
|
||||
|
||||
func validMode(mode Mode) bool {
|
||||
switch mode {
|
||||
case ModeOff, ModeMCP, ModePicoclaw:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func elapsedMilliseconds(startedAt time.Time) int64 {
|
||||
return time.Since(startedAt).Milliseconds()
|
||||
}
|
||||
|
||||
func logModeSwitchFailure(message string, fields log.Fields, err error, startedAt time.Time) {
|
||||
fields["total_ms"] = elapsedMilliseconds(startedAt)
|
||||
log.WithFields(fields).WithError(err).Warn(message)
|
||||
}
|
||||
202
server/service/controlmode/manager_test.go
Normal file
202
server/service/controlmode/manager_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package controlmode
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestManagerDefaultsToPicoclaw(t *testing.T) {
|
||||
manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModePicoclaw)
|
||||
if got := manager.Current(); got != ModePicoclaw {
|
||||
t.Fatalf("mode = %q, want %q", got, ModePicoclaw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchPreemptsBeforeWaitingForActiveWrite(t *testing.T) {
|
||||
manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModeMCP)
|
||||
release, err := manager.AcquireWrite(ModeMCP)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
preempted := make(chan struct{})
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- manager.Switch(ModePicoclaw, func() error {
|
||||
close(preempted)
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-preempted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("preempt callback was not called before waiting")
|
||||
}
|
||||
if _, err := manager.AcquireWrite(ModeMCP); err == nil {
|
||||
t.Fatal("new write acquired control during transition")
|
||||
}
|
||||
|
||||
release()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("switch did not resume after active write was released")
|
||||
}
|
||||
if got := manager.Current(); got != ModePicoclaw {
|
||||
t.Fatalf("mode = %q, want picoclaw", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchRunsCleanupAfterActiveWritesDrain(t *testing.T) {
|
||||
manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModePicoclaw)
|
||||
release, err := manager.AcquireWrite(ModePicoclaw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cleanupCalled := make(chan struct{})
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- manager.SwitchWithCleanup(ModeMCP, nil, func() error {
|
||||
close(cleanupCalled)
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-cleanupCalled:
|
||||
t.Fatal("cleanup ran before active write drained")
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
|
||||
release()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("switch did not finish")
|
||||
}
|
||||
select {
|
||||
case <-cleanupCalled:
|
||||
default:
|
||||
t.Fatal("cleanup was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchCleanupFailureFailsClosed(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mode")
|
||||
manager := NewManager(path, ModePicoclaw)
|
||||
wantErr := errors.New("hid release failed")
|
||||
|
||||
if err := manager.SwitchWithCleanup(ModeMCP, nil, func() error { return wantErr }); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if got := manager.Current(); got != ModeOff {
|
||||
t.Fatalf("mode = %q, want off after cleanup failure", got)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "off\n" {
|
||||
t.Fatalf("mode file = %q, want off", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchIfKeepsDifferentMode(t *testing.T) {
|
||||
manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModePicoclaw)
|
||||
switched, err := manager.SwitchIf(ModeMCP, ModeOff, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if switched || manager.Current() != ModePicoclaw {
|
||||
t.Fatalf("switched=%v mode=%q, want unchanged picoclaw", switched, manager.Current())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchTimeoutDoesNotLeaveManagerTransitioning(t *testing.T) {
|
||||
manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModeMCP)
|
||||
manager.activityWaitTimeout = 20 * time.Millisecond
|
||||
cleanupCalled := false
|
||||
|
||||
status, release, err := manager.AcquireStable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.Mode != ModeMCP {
|
||||
t.Fatalf("mode = %q, want MCP", status.Mode)
|
||||
}
|
||||
|
||||
err = manager.SwitchWithCleanup(ModePicoclaw, nil, func() error {
|
||||
cleanupCalled = true
|
||||
return nil
|
||||
})
|
||||
if !errors.Is(err, ErrActivityWaitTimeout) {
|
||||
t.Fatalf("switch error = %v, want %v", err, ErrActivityWaitTimeout)
|
||||
}
|
||||
if cleanupCalled {
|
||||
t.Fatal("cleanup ran even though exclusive activity lease was not acquired")
|
||||
}
|
||||
status, err = manager.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.Mode != ModeMCP || status.Transitioning {
|
||||
t.Fatalf("status after timeout = %+v, want stable MCP", status)
|
||||
}
|
||||
|
||||
release()
|
||||
if err := manager.Switch(ModePicoclaw, nil); err != nil {
|
||||
t.Fatalf("switch after lease release failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidModeFailsClosed(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mode")
|
||||
if err := os.WriteFile(path, []byte("invalid\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager := NewManager(path, ModePicoclaw)
|
||||
status, err := manager.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.Mode != ModeOff {
|
||||
t.Fatalf("mode = %q, want off", status.Mode)
|
||||
}
|
||||
if !strings.Contains(status.LastError, "invalid") {
|
||||
t.Fatalf("last_error = %q, want invalid mode detail", status.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusReloadsExternallyModifiedModeFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mode")
|
||||
if err := os.WriteFile(path, []byte("off\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager := NewManager(path, ModePicoclaw)
|
||||
if got := manager.Current(); got != ModeOff {
|
||||
t.Fatalf("initial mode = %q, want off", got)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte("mcp\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
modTime := time.Now().Add(time.Second)
|
||||
if err := os.Chtimes(path, modTime, modTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := manager.Current(); got != ModeMCP {
|
||||
t.Fatalf("mode after external write = %q, want MCP", got)
|
||||
}
|
||||
}
|
||||
@@ -2,286 +2,409 @@ package download
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/utils"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type Service struct{}
|
||||
type downloadStatus string
|
||||
|
||||
var sentinelPath = "/tmp/.download_in_progress"
|
||||
const (
|
||||
transferSentinelPath = utils.TransferSentinelPath
|
||||
downloadStatusIdle downloadStatus = "idle"
|
||||
downloadStatusInProgress downloadStatus = "in_progress"
|
||||
downloadStatusSuccess downloadStatus = "success"
|
||||
downloadStatusFailed downloadStatus = "failed"
|
||||
downloadStatusChecksumFailed downloadStatus = "checksum_failed"
|
||||
|
||||
imageDirectory = "/data"
|
||||
|
||||
// ISO 9660 and UDF volume descriptors live in sector 16 of the image.
|
||||
volumeDescriptorOffset = 0x8001
|
||||
volumeDescriptorSize = 5
|
||||
// Only the descriptors are read before the image is stored, so an unusable
|
||||
// image is rejected without transferring all of it.
|
||||
imageHeaderSize = volumeDescriptorOffset + volumeDescriptorSize
|
||||
// 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")
|
||||
errUnsupportedImage = errors.New("unsupported image format: expected an ISO 9660/UDF image or a raw disk image")
|
||||
validISOFilename = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
downloadMutex sync.Mutex
|
||||
downloadCancel context.CancelFunc
|
||||
downloadDone chan struct{}
|
||||
downloadStatus downloadStatus
|
||||
downloadFile string
|
||||
downloadPercentage string
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
// Clear sentinel
|
||||
// If we are starting from scratch, we need to remove the sentinel file as any downloads at this point are done or broken
|
||||
_ = os.Remove(sentinelPath)
|
||||
return &Service{}
|
||||
// A running transfer cannot survive a server restart, so any remaining lock
|
||||
// file is stale at this point.
|
||||
_ = os.Remove(transferSentinelPath)
|
||||
return &Service{downloadStatus: downloadStatusIdle}
|
||||
}
|
||||
|
||||
func (s *Service) CancelDownloadImage(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
s.downloadMutex.Lock()
|
||||
cancel := s.downloadCancel
|
||||
done := s.downloadDone
|
||||
s.downloadMutex.Unlock()
|
||||
|
||||
if cancel == nil || done == nil {
|
||||
rsp.ErrRsp(c, -1, "no cancellable download in progress")
|
||||
return
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
rsp.OkRsp(c)
|
||||
case <-time.After(10 * time.Second):
|
||||
rsp.ErrRsp(c, -1, "cancel download timed out")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) beginDownload(file string, cancel context.CancelFunc) (chan struct{}, error) {
|
||||
s.downloadMutex.Lock()
|
||||
defer s.downloadMutex.Unlock()
|
||||
|
||||
if s.downloadDone != nil {
|
||||
return nil, errDownloadInProgress
|
||||
}
|
||||
|
||||
lockFile, err := os.OpenFile(
|
||||
transferSentinelPath,
|
||||
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
||||
0o644,
|
||||
)
|
||||
if err != nil {
|
||||
if os.IsExist(err) {
|
||||
return nil, errDownloadInProgress
|
||||
}
|
||||
return nil, fmt.Errorf("acquire transfer lock failed: %w", err)
|
||||
}
|
||||
if err := lockFile.Close(); err != nil {
|
||||
_ = os.Remove(transferSentinelPath)
|
||||
return nil, fmt.Errorf("close transfer lock failed: %w", err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
s.downloadCancel = cancel
|
||||
s.downloadDone = done
|
||||
s.downloadStatus = downloadStatusInProgress
|
||||
s.downloadFile = file
|
||||
s.downloadPercentage = ""
|
||||
|
||||
return done, nil
|
||||
}
|
||||
|
||||
func (s *Service) setDownloadFile(done chan struct{}, file string) {
|
||||
s.downloadMutex.Lock()
|
||||
defer s.downloadMutex.Unlock()
|
||||
|
||||
if s.downloadDone == done {
|
||||
s.downloadFile = file
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) setDownloadProgress(done chan struct{}, percentage string) {
|
||||
s.downloadMutex.Lock()
|
||||
defer s.downloadMutex.Unlock()
|
||||
|
||||
if s.downloadDone == done {
|
||||
s.downloadPercentage = percentage
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) finishDownload(done chan struct{}, status downloadStatus) {
|
||||
s.downloadMutex.Lock()
|
||||
if s.downloadDone != done {
|
||||
s.downloadMutex.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
s.downloadCancel = nil
|
||||
s.downloadDone = nil
|
||||
s.downloadStatus = status
|
||||
s.downloadFile = ""
|
||||
s.downloadPercentage = ""
|
||||
_ = os.Remove(transferSentinelPath)
|
||||
s.downloadMutex.Unlock()
|
||||
|
||||
close(done)
|
||||
}
|
||||
|
||||
func (s *Service) ImageEnabled(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
// Check if /data mount is RO/RW
|
||||
testFile := "/data/.testfile"
|
||||
file, err := os.Create(testFile)
|
||||
defer file.Close()
|
||||
defer os.Remove(testFile)
|
||||
if err != nil {
|
||||
if os.IsPermission(err) {
|
||||
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{
|
||||
Enabled: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{
|
||||
Enabled: false,
|
||||
})
|
||||
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{Enabled: false})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
defer os.Remove(testFile)
|
||||
|
||||
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{
|
||||
Enabled: true,
|
||||
})
|
||||
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" bei Offset 32769
|
||||
_, err = f.Seek(0x8001, io.SeekStart)
|
||||
if err != nil {
|
||||
return false, err
|
||||
// isSupportedImage reports whether the uploaded image can be exposed through
|
||||
// the USB mass-storage gadget. ISO 9660 images and UDF bridges, which is what
|
||||
// bootable installer ISOs are normally built as, carry "CD001" in the volume
|
||||
// descriptor sector, while UDF-only images carry "BEA01" there.
|
||||
func isSupportedImage(head []byte) bool {
|
||||
if len(head) >= volumeDescriptorOffset+volumeDescriptorSize {
|
||||
switch string(head[volumeDescriptorOffset : volumeDescriptorOffset+volumeDescriptorSize]) {
|
||||
case "CD001", "BEA01":
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
buf := make([]byte, 5)
|
||||
_, err = io.ReadFull(f, buf)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return looksLikeDiskImage(head)
|
||||
}
|
||||
|
||||
// looksLikeDiskImage reports whether the image starts with a partition table or
|
||||
// a filesystem that a target machine can mount on its own, which covers raw
|
||||
// disk images such as macOS installers converted from a DMG.
|
||||
func looksLikeDiskImage(head []byte) bool {
|
||||
if len(head) < 0x200 {
|
||||
return false
|
||||
}
|
||||
|
||||
return string(buf) == "CD001", nil
|
||||
if len(head) >= 0x208 && string(head[0x200:0x208]) == "EFI PART" { // GPT header
|
||||
return true
|
||||
}
|
||||
|
||||
if len(head) >= 0x24 && string(head[0x20:0x24]) == "NXSB" { // APFS container superblock
|
||||
return true
|
||||
}
|
||||
|
||||
if len(head) >= 0x402 {
|
||||
switch string(head[0x400:0x402]) { // HFS+/HFS volume header
|
||||
case "H+", "BD":
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MBR partition table. FAT, exFAT and NTFS volume images carry the same
|
||||
// signature in their boot sector.
|
||||
return head[0x1FE] == 0x55 && head[0x1FF] == 0xAA
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Service) StatusImage(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
// Check if the sentinel file exists
|
||||
log.Debug("StatusImage")
|
||||
if _, err := os.Stat(sentinelPath); err == nil {
|
||||
content, err := os.ReadFile(sentinelPath)
|
||||
if err != nil {
|
||||
log.Error("Failed to read sentinel file")
|
||||
rsp.OkRspWithData(c, &proto.StatusImageRsp{
|
||||
Status: "in_progress",
|
||||
File: "",
|
||||
Percentage: "",
|
||||
})
|
||||
return
|
||||
}
|
||||
splitted := strings.Split(string(content), ";")
|
||||
if len(splitted) == 1 {
|
||||
// No percentage, just the URL
|
||||
rsp.OkRspWithData(c, &proto.StatusImageRsp{
|
||||
Status: "in_progress",
|
||||
File: splitted[0],
|
||||
Percentage: "",
|
||||
})
|
||||
} else {
|
||||
// Percentage is available
|
||||
rsp.OkRspWithData(c, &proto.StatusImageRsp{
|
||||
Status: "in_progress",
|
||||
File: splitted[0],
|
||||
Percentage: splitted[1],
|
||||
})
|
||||
}
|
||||
s.downloadMutex.Lock()
|
||||
status := s.downloadStatus
|
||||
file := s.downloadFile
|
||||
percentage := s.downloadPercentage
|
||||
s.downloadMutex.Unlock()
|
||||
|
||||
return
|
||||
}
|
||||
rsp.OkRspWithData(c, &proto.StatusImageRsp{
|
||||
Status: "idle",
|
||||
File: "",
|
||||
Percentage: "",
|
||||
Status: string(status),
|
||||
File: file,
|
||||
Percentage: percentage,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
log.Debug("DownloadImage")
|
||||
|
||||
// Set a sentinel file to mark that there is a download in progress
|
||||
// This is to prevent multiple downloads at the same time
|
||||
if _, err := os.Stat(sentinelPath); err == nil {
|
||||
log.Debug("Download in progress")
|
||||
rsp.ErrRsp(c, -1, "download in progress")
|
||||
return
|
||||
}
|
||||
|
||||
// Create the sentinel file
|
||||
err := os.WriteFile(sentinelPath, []byte("start"), 0644)
|
||||
log.Debug("DownloadImageFile")
|
||||
expectedSHA256, err := parseSHA256(c.GetHeader("X-SHA256-Sum"))
|
||||
if err != nil {
|
||||
log.Error("Failed to create sentinel file")
|
||||
rsp.ErrRsp(c, -1, "failed to create sentinel file")
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Multipart Reader direkt nutzen (keine FormFile!)
|
||||
reader, err := c.Request.MultipartReader()
|
||||
if err != nil {
|
||||
// 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())
|
||||
return
|
||||
}
|
||||
finalStatus := downloadStatusFailed
|
||||
defer func() {
|
||||
s.finishDownload(done, finalStatus)
|
||||
}()
|
||||
|
||||
reader, err := c.Request.MultipartReader()
|
||||
if err != nil {
|
||||
log.Error("invalid multipart data")
|
||||
rsp.ErrRsp(c, -1, "invalid multipart data")
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
var lw *loggingWriter
|
||||
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("failed to read part")
|
||||
rsp.ErrRsp(c, -1, "failed to read part")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
if part.FormName() != "file" {
|
||||
continue
|
||||
}
|
||||
|
||||
filename := part.FileName()
|
||||
if filename == "" {
|
||||
log.Error("no filename")
|
||||
rsp.ErrRsp(c, -1, "no filename")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
filename = filepath.Base(filename)
|
||||
|
||||
if filename != part.FileName() {
|
||||
log.Warn("path detected in filename")
|
||||
rsp.ErrRsp(c, -1, "invalid filename")
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(filename, "..") {
|
||||
log.Warn("path traversal attempt")
|
||||
rsp.ErrRsp(c, -1, "invalid filename")
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
if part.FormName() != "file" {
|
||||
_ = part.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(strings.ToLower(filename), ".iso") {
|
||||
rsp.ErrRsp(c, -1, "only .iso files allowed")
|
||||
defer os.Remove(sentinelPath)
|
||||
filename := part.FileName()
|
||||
if err := validateISOFilename(filename); err != nil {
|
||||
log.Warnf("reject uploaded image %q: %s", filename, err)
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
s.setDownloadFile(done, filename)
|
||||
|
||||
valid := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
||||
if !valid.MatchString(filename) {
|
||||
rsp.ErrRsp(c, -1, "invalid filename")
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(sentinelPath)
|
||||
// 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, ".nanokvm-upload-*")
|
||||
if err != nil {
|
||||
log.Error("Read failed")
|
||||
rsp.ErrRsp(c, -1, "Read failed")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(sentinelPath)
|
||||
log.Errorf("cannot create temporary file in %s: %s", imageDirectory, err)
|
||||
rsp.ErrRsp(c, -1, "cannot create temporary file")
|
||||
return
|
||||
}
|
||||
tempPath := out.Name()
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
outPath := "/data/" + filename
|
||||
out, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
log.Error("cannot create file")
|
||||
rsp.ErrRsp(c, -1, "cannot create file")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
hasher := sha256.New()
|
||||
lw := newLoggingWriter(io.MultiWriter(out, hasher), c.Request.ContentLength, func(percentage string) {
|
||||
s.setDownloadProgress(done, percentage)
|
||||
})
|
||||
defer lw.stopTicker()
|
||||
|
||||
if strings.Contains(string(data), "start") {
|
||||
err = os.WriteFile(sentinelPath, []byte(filename), 0644)
|
||||
if err != nil {
|
||||
log.Error("Failed to create sentinel file")
|
||||
rsp.ErrRsp(c, -1, "failed to create sentinel file")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(outPath)
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
lw = &loggingWriter{writer: out, totalSize: c.Request.ContentLength}
|
||||
lw.startTicker()
|
||||
} else {
|
||||
if !strings.Contains(string(data), filename) {
|
||||
log.Error("failed")
|
||||
rsp.ErrRsp(c, -1, "failed")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(outPath)
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Direkt streamen → kein RAM-Bedarf außer kleinem Buffer
|
||||
_, err = io.Copy(lw, part)
|
||||
if err != nil {
|
||||
log.Error("write failed")
|
||||
// Check the volume descriptors while streaming, so an unusable image is
|
||||
// rejected in seconds instead of after the whole upload.
|
||||
head := make([]byte, imageHeaderSize)
|
||||
headSize, readErr := io.ReadFull(part, head)
|
||||
if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF {
|
||||
log.Errorf("read uploaded image %q failed: %s", filename, readErr)
|
||||
rsp.ErrRsp(c, -1, "write failed")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(outPath)
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
ok, err := isISO9660(outPath)
|
||||
if err != nil || !ok {
|
||||
rsp.ErrRsp(c, -1, "file is not a valid ISO image")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(outPath)
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
lw.stopTicker()
|
||||
if !isSupportedImage(head[:headSize]) {
|
||||
log.Warnf("reject uploaded image %q: %s", filename, errUnsupportedImage)
|
||||
rsp.ErrRsp(c, -1, errUnsupportedImage.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.StatusImageRsp{
|
||||
Status: "idle",
|
||||
File: "",
|
||||
Percentage: "",
|
||||
})
|
||||
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
_, copyErr := lw.Write(head[:headSize])
|
||||
if copyErr == nil {
|
||||
_, copyErr = io.Copy(lw, part)
|
||||
}
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil || closeErr != nil {
|
||||
log.Errorf("write uploaded image %q failed: %s", filename, errors.Join(copyErr, closeErr))
|
||||
rsp.ErrRsp(c, -1, "write failed")
|
||||
return
|
||||
}
|
||||
|
||||
if expectedSHA256 != nil && !bytes.Equal(hasher.Sum(nil), expectedSHA256) {
|
||||
finalStatus = downloadStatusChecksumFailed
|
||||
rsp.ErrRsp(c, -1, errSHA256Mismatch.Error())
|
||||
return
|
||||
}
|
||||
|
||||
outPath := filepath.Join(imageDirectory, filename)
|
||||
if err := os.Rename(tempPath, outPath); err != nil {
|
||||
rsp.ErrRsp(c, -1, "cannot install uploaded image")
|
||||
return
|
||||
}
|
||||
|
||||
finalStatus = downloadStatusIdle
|
||||
rsp.OkRspWithData(c, &proto.StatusImageRsp{
|
||||
Status: string(downloadStatusIdle),
|
||||
File: "",
|
||||
Percentage: "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
rsp.ErrRsp(c, -1, "file is required")
|
||||
}
|
||||
|
||||
func validateISOFilename(filename string) error {
|
||||
if filename == "" {
|
||||
return errors.New("no filename")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Service) DownloadImage(c *gin.Context) {
|
||||
var req proto.MountImageReq
|
||||
var req proto.DownloadImageReq
|
||||
var rsp proto.Response
|
||||
|
||||
log.Debug("DownloadImage")
|
||||
@@ -291,127 +414,190 @@ func (s *Service) DownloadImage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.File == "" {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
expectedSHA256, err := parseSHA256(req.SHA256Sum)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
// Parse the URI to see if its valid http/s
|
||||
|
||||
u, err := url.Parse(req.File)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Path == "" {
|
||||
rsp.ErrRsp(c, -1, "invalid url")
|
||||
return
|
||||
}
|
||||
filename := filepath.Base(u.Path)
|
||||
if filename == "." || filename == "/" || filename == "" {
|
||||
rsp.ErrRsp(c, -1, "invalid url")
|
||||
return
|
||||
}
|
||||
|
||||
// Set a sentinel file to mark that there is a download in progress
|
||||
// This is to prevent multiple downloads at the same time
|
||||
if _, err := os.Stat(sentinelPath); err == nil {
|
||||
log.Debug("Download in progress")
|
||||
rsp.ErrRsp(c, -1, "download in progress")
|
||||
return
|
||||
}
|
||||
// Create the sentinel file
|
||||
err = os.WriteFile(sentinelPath, []byte(req.File), 0644)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done, err := s.beginDownload(req.File, cancel)
|
||||
if err != nil {
|
||||
log.Error("Failed to create sentinel file")
|
||||
rsp.ErrRsp(c, -1, "failed to create sentinel file")
|
||||
cancel()
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it actually exists and fail if it doesn't
|
||||
resp, err := http.Head(req.File)
|
||||
if resp.StatusCode != http.StatusOK || err != nil {
|
||||
rsp.ErrRsp(c, resp.StatusCode, "failed when checking the url")
|
||||
log.Error("Failed to check the URL")
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Download the image in a goroutine to not block the request
|
||||
go func() {
|
||||
defer os.Remove(sentinelPath)
|
||||
resp, err = http.Get(req.File)
|
||||
if err != nil {
|
||||
log.Error("Failed to download the file")
|
||||
rsp.ErrRsp(c, -1, "failed to download the file")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Create the destination file
|
||||
destPath := filepath.Join("/data", filepath.Base(u.Path))
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
log.Error("Failed to create destination file")
|
||||
rsp.ErrRsp(c, -1, "failed to create destination file")
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
defer cancel()
|
||||
|
||||
lw := &loggingWriter{writer: out, totalSize: resp.ContentLength}
|
||||
lw.startTicker()
|
||||
_, err = io.Copy(lw, resp.Body)
|
||||
if err != nil {
|
||||
log.Error("Failed to save the file")
|
||||
rsp.ErrRsp(c, -1, "failed to save the file")
|
||||
lw.stopTicker()
|
||||
if err := s.downloadRemoteImage(ctx, req.File, expectedSHA256, filename, func(percentage string) {
|
||||
s.setDownloadProgress(done, percentage)
|
||||
}); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
log.Debug("Image download canceled")
|
||||
s.finishDownload(done, downloadStatusIdle)
|
||||
return
|
||||
}
|
||||
|
||||
log.Errorf("Failed to download image: %v", err)
|
||||
status := downloadStatusFailed
|
||||
if errors.Is(err, errSHA256Mismatch) {
|
||||
status = downloadStatusChecksumFailed
|
||||
}
|
||||
s.finishDownload(done, status)
|
||||
return
|
||||
}
|
||||
lw.stopTicker()
|
||||
s.finishDownload(done, downloadStatusSuccess)
|
||||
}()
|
||||
|
||||
rsp.OkRspWithData(c, &proto.StatusImageRsp{
|
||||
Status: "in_progress",
|
||||
Status: string(downloadStatusInProgress),
|
||||
File: req.File,
|
||||
Percentage: "",
|
||||
})
|
||||
}
|
||||
|
||||
func parseSHA256(value string) ([]byte, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sum, err := hex.DecodeString(value)
|
||||
if err != nil || len(sum) != sha256.Size {
|
||||
return nil, errors.New("invalid sha256sum")
|
||||
}
|
||||
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
func (s *Service) downloadRemoteImage(
|
||||
ctx context.Context,
|
||||
rawURL string,
|
||||
expectedSHA256 []byte,
|
||||
filename string,
|
||||
onProgress func(string),
|
||||
) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create download request failed: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download request returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp(imageDirectory, ".nanokvm-download-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary image failed: %w", err)
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
hasher := sha256.New()
|
||||
lw := newLoggingWriter(io.MultiWriter(tempFile, hasher), resp.ContentLength, onProgress)
|
||||
_, copyErr := io.Copy(lw, resp.Body)
|
||||
lw.stopTicker()
|
||||
closeErr := tempFile.Close()
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("save downloaded image failed: %w", copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close downloaded image failed: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSHA256 != nil && !bytes.Equal(hasher.Sum(nil), expectedSHA256) {
|
||||
return errSHA256Mismatch
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
destPath := filepath.Join(imageDirectory, filename)
|
||||
if err := os.Rename(tempPath, destPath); err != nil {
|
||||
return fmt.Errorf("install downloaded image failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type loggingWriter struct {
|
||||
writer io.Writer
|
||||
total int64
|
||||
totalSize int64
|
||||
ticker *time.Ticker
|
||||
done chan bool
|
||||
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 bool)
|
||||
lw.done = make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-lw.done:
|
||||
return
|
||||
case <-lw.ticker.C:
|
||||
lw.updateSentinel()
|
||||
lw.updateProgress()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (lw *loggingWriter) stopTicker() {
|
||||
lw.ticker.Stop()
|
||||
lw.done <- true
|
||||
if lw == nil {
|
||||
return
|
||||
}
|
||||
lw.stopOnce.Do(func() {
|
||||
lw.ticker.Stop()
|
||||
close(lw.done)
|
||||
})
|
||||
}
|
||||
|
||||
func (lw *loggingWriter) updateSentinel() {
|
||||
percentage := float64(lw.total) / float64(lw.totalSize) * 100
|
||||
content, err := os.ReadFile(sentinelPath)
|
||||
if err != nil {
|
||||
log.Error("Failed to read sentinel file")
|
||||
func (lw *loggingWriter) updateProgress() {
|
||||
if lw.totalSize <= 0 || lw.onProgress == nil {
|
||||
return
|
||||
}
|
||||
splitted := strings.Split(string(content), ";")
|
||||
if len(splitted) == 0 {
|
||||
return
|
||||
}
|
||||
err = os.WriteFile(sentinelPath, []byte(fmt.Sprintf("%s;%.2f%%", splitted[0], percentage)), 0644)
|
||||
if err != nil {
|
||||
log.Error("Failed to update sentinel file")
|
||||
}
|
||||
|
||||
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 += int64(n)
|
||||
lw.total.Add(int64(n))
|
||||
return n, err
|
||||
}
|
||||
|
||||
73
server/service/download/service_test.go
Normal file
73
server/service/download/service_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func headWithDescriptor(magic string) []byte {
|
||||
head := make([]byte, imageHeaderSize)
|
||||
copy(head[volumeDescriptorOffset:], magic)
|
||||
return head
|
||||
}
|
||||
|
||||
func TestIsSupportedImage(t *testing.T) {
|
||||
mbr := make([]byte, imageHeaderSize)
|
||||
mbr[0x1FE], mbr[0x1FF] = 0x55, 0xAA
|
||||
|
||||
gpt := make([]byte, imageHeaderSize)
|
||||
copy(gpt[0x200:], "EFI PART")
|
||||
|
||||
apfs := make([]byte, imageHeaderSize)
|
||||
copy(apfs[0x20:], "NXSB")
|
||||
|
||||
hfs := make([]byte, imageHeaderSize)
|
||||
copy(hfs[0x400:], "H+")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
head []byte
|
||||
want bool
|
||||
}{
|
||||
{"iso9660 image", headWithDescriptor("CD001"), true},
|
||||
{"udf bridge image", headWithDescriptor("CD001"), true},
|
||||
{"udf only image", headWithDescriptor("BEA01"), true},
|
||||
{"gpt disk image", gpt, true},
|
||||
{"apfs disk image", apfs, true},
|
||||
{"hfs+ disk image", hfs, true},
|
||||
{"mbr disk image", mbr, true},
|
||||
{"compressed dmg", make([]byte, imageHeaderSize), false},
|
||||
{"plain data", bytes.Repeat([]byte{0xA5}, imageHeaderSize), false},
|
||||
{"truncated image", headWithDescriptor("CD001")[:16], false},
|
||||
{"empty image", nil, false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := isSupportedImage(test.head); got != test.want {
|
||||
t.Fatalf("isSupportedImage(%s) = %v, want %v", test.name, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureImageSpace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
if err := ensureImageSpace(dir, 0); err != nil {
|
||||
t.Fatalf("an upload without a known size must not be rejected: %v", err)
|
||||
}
|
||||
|
||||
if err := ensureImageSpace(dir, 1<<20); err != nil {
|
||||
t.Fatalf("a small upload must fit: %v", err)
|
||||
}
|
||||
|
||||
if err := ensureImageSpace(dir, 1<<62); err == nil {
|
||||
t.Fatal("an upload larger than the filesystem must be rejected")
|
||||
}
|
||||
|
||||
if err := ensureImageSpace(filepath.Join(dir, "missing"), 1<<20); err == nil {
|
||||
t.Fatal("a missing directory must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,17 @@ import (
|
||||
)
|
||||
|
||||
type Hid struct {
|
||||
g0 *os.File
|
||||
g1 *os.File
|
||||
g2 *os.File
|
||||
kbMutex sync.Mutex
|
||||
mouseMutex sync.Mutex
|
||||
g0 *os.File
|
||||
g0Reader *os.File
|
||||
g1 *os.File
|
||||
g2 *os.File
|
||||
ledReaderNotifyReader *os.File
|
||||
ledReaderNotifyWriter *os.File
|
||||
ledReaderNotifyReadFD int
|
||||
ledReaderNotifyWriteFD int
|
||||
kbMutex sync.Mutex
|
||||
mouseMutex sync.Mutex
|
||||
ledReaderStartOnce sync.Once
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -124,6 +130,15 @@ func (h *Hid) OpenNoLock() error {
|
||||
}
|
||||
}
|
||||
|
||||
if h.g0 != nil {
|
||||
if err := h.openKeyboardLedReaderNoLock(); err != nil {
|
||||
log.Errorf("open keyboard LED reader failed: %s", err)
|
||||
errs = append(errs, err)
|
||||
} else {
|
||||
h.startKeyboardLedReader()
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
@@ -162,6 +177,8 @@ func openNoLockWithRetry(open func() error, timeout, delay time.Duration) error
|
||||
}
|
||||
|
||||
func (h *Hid) CloseNoLock() {
|
||||
h.closeKeyboardLedReaderNoLock()
|
||||
|
||||
for _, device := range h.devices() {
|
||||
h.closeDeviceNoLock(device)
|
||||
}
|
||||
@@ -193,6 +210,41 @@ func (h *Hid) closeDeviceNoLock(device hidDevice) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) openKeyboardLedReaderNoLock() error {
|
||||
if h.g0Reader != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := h.ensureKeyboardLedReaderNotifierNoLock(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Keep this descriptor blocking. The LED reader waits for either an output
|
||||
// report or a lifecycle notification, so an idle host does not cause a
|
||||
// periodic EAGAIN retry loop.
|
||||
file, err := os.OpenFile(HID0, os.O_RDONLY, 0o666)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", HID0, err)
|
||||
}
|
||||
|
||||
h.g0Reader = file
|
||||
h.notifyKeyboardLedReaderNoLock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hid) closeKeyboardLedReaderNoLock() {
|
||||
if h.g0Reader == nil {
|
||||
return
|
||||
}
|
||||
|
||||
file := h.g0Reader
|
||||
h.g0Reader = nil
|
||||
h.notifyKeyboardLedReaderNoLock()
|
||||
if err := file.Close(); err != nil {
|
||||
log.Debugf("close keyboard LED reader failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeWithTimeout bounds how long callers hold HID locks when writing to a
|
||||
// nonblocking descriptor. EAGAIN means the host is not accepting HID reports
|
||||
// yet, so retry until the caller's deadline expires.
|
||||
@@ -249,15 +301,42 @@ func (h *Hid) Close() {
|
||||
}
|
||||
|
||||
func (h *Hid) WriteHid0(data []byte) {
|
||||
h.writeHIDReport(h.keyboardDevice(HID0), data)
|
||||
if err := h.WriteKeyboardReport(data); err != nil {
|
||||
log.Errorf("write to %s failed: %s", HID0, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) WriteHid1(data []byte) {
|
||||
h.writeHIDReport(h.relativeMouseDevice(HID1), data)
|
||||
if err := h.WriteRelativeMouseReport(data); err != nil {
|
||||
log.Errorf("write to %s failed: %s", HID1, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) WriteHid2(data []byte) {
|
||||
h.writeHIDReport(h.absoluteMouseDevice(HID2), data)
|
||||
if err := h.WriteAbsoluteMouseReport(data); err != nil {
|
||||
log.Errorf("write to %s failed: %s", HID2, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) WriteKeyboardReport(data []byte) error {
|
||||
if len(data) != 8 {
|
||||
return fmt.Errorf("invalid keyboard report length: %d", len(data))
|
||||
}
|
||||
return h.writeHID(h.keyboardDevice(HID0), data)
|
||||
}
|
||||
|
||||
func (h *Hid) WriteRelativeMouseReport(data []byte) error {
|
||||
if len(data) != 4 {
|
||||
return fmt.Errorf("invalid relative mouse report length: %d", len(data))
|
||||
}
|
||||
return h.writeHID(h.relativeMouseDevice(HID1), data)
|
||||
}
|
||||
|
||||
func (h *Hid) WriteAbsoluteMouseReport(data []byte) error {
|
||||
if len(data) != 6 {
|
||||
return fmt.Errorf("invalid absolute mouse report length: %d", len(data))
|
||||
}
|
||||
return h.writeHID(h.absoluteMouseDevice(HID2), data)
|
||||
}
|
||||
|
||||
func (h *Hid) writeHIDReport(device hidDevice, data []byte) bool {
|
||||
@@ -275,6 +354,13 @@ func (h *Hid) writeHID(device hidDevice, data []byte) error {
|
||||
if err := h.openDeviceNoLock(device); err != nil {
|
||||
return err
|
||||
}
|
||||
if device.path == HID0 {
|
||||
if err := h.openKeyboardLedReaderNoLock(); err != nil {
|
||||
log.Debugf("open keyboard LED reader failed: %s", err)
|
||||
} else {
|
||||
h.startKeyboardLedReader()
|
||||
}
|
||||
}
|
||||
|
||||
file := device.get()
|
||||
if file == nil {
|
||||
@@ -287,6 +373,9 @@ func (h *Hid) writeHID(device hidDevice, data []byte) error {
|
||||
}
|
||||
|
||||
if err := writeWithTimeout(file, data, hidWriteTimeout); err != nil {
|
||||
if device.path == HID0 {
|
||||
h.closeKeyboardLedReaderNoLock()
|
||||
}
|
||||
h.closeDeviceNoLock(device)
|
||||
switch {
|
||||
case errors.Is(err, os.ErrClosed):
|
||||
|
||||
28
server/service/hid/hid_test.go
Normal file
28
server/service/hid/hid_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReportLengthValidation(t *testing.T) {
|
||||
h := &Hid{}
|
||||
if err := h.WriteKeyboardReport(make([]byte, 7)); err == nil {
|
||||
t.Fatal("expected keyboard length error")
|
||||
}
|
||||
if err := h.WriteRelativeMouseReport(make([]byte, 5)); err == nil {
|
||||
t.Fatal("expected relative mouse length error")
|
||||
}
|
||||
if err := h.WriteAbsoluteMouseReport(make([]byte, 7)); err == nil {
|
||||
t.Fatal("expected absolute mouse length error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasteDurationLeavesModeSwitchMargin(t *testing.T) {
|
||||
if maxPasteDuration >= 30*time.Second {
|
||||
t.Fatalf("maxPasteDuration = %s, want below 30s mode switch wait budget", maxPasteDuration)
|
||||
}
|
||||
if got := time.Duration(maxPasteContentRunes) * defaultPasteDelay; got > maxPasteDuration {
|
||||
t.Fatalf("max paste content duration = %s, want <= %s", got, maxPasteDuration)
|
||||
}
|
||||
}
|
||||
@@ -5,43 +5,105 @@ import (
|
||||
)
|
||||
|
||||
func (h *Hid) Keyboard(queue <-chan []byte) {
|
||||
h.keyboard(queue, HID0)
|
||||
legacy := make(chan QueuedReport)
|
||||
go func() {
|
||||
defer close(legacy)
|
||||
for report := range queue {
|
||||
legacy <- QueuedReport{Data: report}
|
||||
}
|
||||
}()
|
||||
h.KeyboardReports(legacy)
|
||||
}
|
||||
|
||||
func (h *Hid) keyboard(queue <-chan []byte, path string) {
|
||||
defer h.releaseKeyboard(path)
|
||||
func (h *Hid) KeyboardReports(queue <-chan QueuedReport) {
|
||||
h.keyboardReports(queue, HID0)
|
||||
}
|
||||
|
||||
func (h *Hid) keyboardReports(queue <-chan QueuedReport, path string) {
|
||||
var execute func(func() error) error
|
||||
var resetKeyboard func()
|
||||
keyboardActive := false
|
||||
defer func() {
|
||||
if !keyboardActive {
|
||||
return
|
||||
}
|
||||
if err := runCleanup(execute, func() error {
|
||||
return h.writeHID(h.keyboardDevice(path), keyboardReleaseReport())
|
||||
}); err != nil {
|
||||
log.Errorf("release keyboard on queue close failed: %s", err)
|
||||
return
|
||||
}
|
||||
if resetKeyboard != nil {
|
||||
resetKeyboard()
|
||||
}
|
||||
}()
|
||||
|
||||
for event := range queue {
|
||||
if len(event) != 8 {
|
||||
log.Debugf("invalid keyboard event: %v", event)
|
||||
execute = event.Execute
|
||||
resetKeyboard = event.ResetKeyboard
|
||||
if len(event.Data) != 8 {
|
||||
event.complete(false)
|
||||
log.Debugf("invalid keyboard event: %v", event.Data)
|
||||
continue
|
||||
}
|
||||
|
||||
if !h.writeHIDReport(h.keyboardDevice(path), event) {
|
||||
if err := event.run(func() error {
|
||||
return h.writeHID(h.keyboardDevice(path), event.Data)
|
||||
}); err != nil {
|
||||
log.Errorf("write to %s failed: %s", path, err)
|
||||
if dropped := drainHIDQueue(queue); dropped > 0 {
|
||||
log.Debugf("dropped %d stale keyboard HID reports after write failure", dropped)
|
||||
}
|
||||
h.releaseKeyboard(path)
|
||||
cleanupErr := runCleanup(execute, func() error {
|
||||
return h.writeHID(h.keyboardDevice(path), keyboardReleaseReport())
|
||||
})
|
||||
if cleanupErr != nil {
|
||||
log.Errorf("release keyboard after write failure failed: %s", cleanupErr)
|
||||
keyboardActive = keyboardActive || keyboardReportActive(event.Data)
|
||||
event.complete(false)
|
||||
continue
|
||||
}
|
||||
keyboardActive = false
|
||||
if resetKeyboard != nil {
|
||||
resetKeyboard()
|
||||
}
|
||||
event.complete(false)
|
||||
continue
|
||||
}
|
||||
|
||||
keyboardActive = keyboardReportActive(event.Data)
|
||||
event.complete(true)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) releaseKeyboard(path string) {
|
||||
h.writeHIDReport(h.keyboardDevice(path), keyboardReleaseReport())
|
||||
func keyboardReportActive(report []byte) bool {
|
||||
if len(report) != 8 {
|
||||
return false
|
||||
}
|
||||
if report[0] != 0 {
|
||||
return true
|
||||
}
|
||||
for _, key := range report[2:] {
|
||||
if key != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func keyboardReleaseReport() []byte {
|
||||
return []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
}
|
||||
|
||||
func drainHIDQueue(queue <-chan []byte) int {
|
||||
func drainHIDQueue(queue <-chan QueuedReport) int {
|
||||
dropped := 0
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-queue:
|
||||
case report, ok := <-queue:
|
||||
if !ok {
|
||||
return dropped
|
||||
}
|
||||
report.complete(false)
|
||||
dropped++
|
||||
default:
|
||||
return dropped
|
||||
|
||||
279
server/service/hid/leds.go
Normal file
279
server/service/hid/leds.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const (
|
||||
KeyboardLedStatusEvent = "hid-led-status"
|
||||
)
|
||||
|
||||
// KeyboardLedStatus is the lock-key LED state last reported by the remote host
|
||||
// through the keyboard HID output report.
|
||||
type KeyboardLedStatus struct {
|
||||
NumLock bool `json:"numLock"`
|
||||
CapsLock bool `json:"capsLock"`
|
||||
ScrollLock bool `json:"scrollLock"`
|
||||
Known bool `json:"known"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type keyboardLedStatusStore struct {
|
||||
mutex sync.RWMutex
|
||||
status KeyboardLedStatus
|
||||
subscribers map[int]func(KeyboardLedStatus)
|
||||
nextID int
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
var keyboardLeds = newKeyboardLedStatusStore(time.Now)
|
||||
|
||||
func newKeyboardLedStatusStore(now func() time.Time) *keyboardLedStatusStore {
|
||||
return &keyboardLedStatusStore{
|
||||
subscribers: make(map[int]func(KeyboardLedStatus)),
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
// GetKeyboardLedStatus returns a snapshot. Known is false until the host has
|
||||
// sent at least one keyboard HID output report.
|
||||
func GetKeyboardLedStatus() KeyboardLedStatus {
|
||||
return keyboardLeds.Get()
|
||||
}
|
||||
|
||||
// SubscribeKeyboardLedStatus subscribes to state changes and returns a
|
||||
// function that removes the subscription.
|
||||
func SubscribeKeyboardLedStatus(subscriber func(KeyboardLedStatus)) func() {
|
||||
return keyboardLeds.Subscribe(subscriber)
|
||||
}
|
||||
|
||||
func (s *keyboardLedStatusStore) Get() KeyboardLedStatus {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
return s.status
|
||||
}
|
||||
|
||||
func (s *keyboardLedStatusStore) Subscribe(subscriber func(KeyboardLedStatus)) func() {
|
||||
s.mutex.Lock()
|
||||
id := s.nextID
|
||||
s.nextID++
|
||||
s.subscribers[id] = subscriber
|
||||
s.mutex.Unlock()
|
||||
|
||||
return func() {
|
||||
s.mutex.Lock()
|
||||
delete(s.subscribers, id)
|
||||
s.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *keyboardLedStatusStore) Update(report byte) {
|
||||
next := keyboardLedStatusFromReport(report, s.now())
|
||||
|
||||
s.mutex.Lock()
|
||||
previous := s.status
|
||||
s.status = next
|
||||
if sameKeyboardLedState(previous, next) {
|
||||
s.mutex.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
subscribers := make([]func(KeyboardLedStatus), 0, len(s.subscribers))
|
||||
for _, subscriber := range s.subscribers {
|
||||
subscribers = append(subscribers, subscriber)
|
||||
}
|
||||
s.mutex.Unlock()
|
||||
|
||||
for _, subscriber := range subscribers {
|
||||
subscriber(next)
|
||||
}
|
||||
}
|
||||
|
||||
func keyboardLedStatusFromReport(report byte, updatedAt time.Time) KeyboardLedStatus {
|
||||
return KeyboardLedStatus{
|
||||
NumLock: report&0x01 != 0,
|
||||
CapsLock: report&0x02 != 0,
|
||||
ScrollLock: report&0x04 != 0,
|
||||
Known: true,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func sameKeyboardLedState(a, b KeyboardLedStatus) bool {
|
||||
return a.NumLock == b.NumLock &&
|
||||
a.CapsLock == b.CapsLock &&
|
||||
a.ScrollLock == b.ScrollLock &&
|
||||
a.Known == b.Known
|
||||
}
|
||||
|
||||
func (h *Hid) startKeyboardLedReader() {
|
||||
h.ledReaderStartOnce.Do(func() {
|
||||
go h.readKeyboardLeds()
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Hid) readKeyboardLeds() {
|
||||
buf := make([]byte, 64)
|
||||
|
||||
for {
|
||||
file, notifierFD := h.keyboardLedReaderHandles()
|
||||
if file == nil {
|
||||
if err := waitForKeyboardLedReaderChange(notifierFD); err != nil {
|
||||
log.Debugf("wait for keyboard LED reader failed: %s", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
changed, err := waitForKeyboardLedReportOrChange(file, notifierFD)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrClosed) {
|
||||
log.Debugf("wait for keyboard LED report failed: %s", err)
|
||||
}
|
||||
h.reopenKeyboardLedReader(file)
|
||||
continue
|
||||
}
|
||||
if changed {
|
||||
continue
|
||||
}
|
||||
|
||||
n, err := file.Read(buf)
|
||||
if n > 0 {
|
||||
// Both keyboard gadget descriptors in kvmapp/system/init.d declare
|
||||
// boot-keyboard reports without a Report ID (no 0x85 item). Their LED
|
||||
// output report is one byte: bits 0-4 are the LED bitmap and bits 5-7
|
||||
// are padding. report_length is 8 because it also covers input reports.
|
||||
keyboardLeds.Update(buf[0])
|
||||
}
|
||||
|
||||
if err == nil && n > 0 {
|
||||
continue
|
||||
}
|
||||
if err != nil && !errors.Is(err, os.ErrClosed) {
|
||||
log.Debugf("read keyboard LED report failed: %s", err)
|
||||
}
|
||||
h.reopenKeyboardLedReader(file)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) keyboardLedReaderHandles() (*os.File, int) {
|
||||
h.kbMutex.Lock()
|
||||
defer h.kbMutex.Unlock()
|
||||
if h.ledReaderNotifyReader == nil {
|
||||
return h.g0Reader, -1
|
||||
}
|
||||
return h.g0Reader, h.ledReaderNotifyReadFD
|
||||
}
|
||||
|
||||
func (h *Hid) reopenKeyboardLedReader(file *os.File) {
|
||||
h.kbMutex.Lock()
|
||||
defer h.kbMutex.Unlock()
|
||||
|
||||
// A reset may have installed a new reader while the old descriptor was
|
||||
// reporting an error. Never let that stale reader close the replacement.
|
||||
if h.g0Reader != file {
|
||||
return
|
||||
}
|
||||
h.closeKeyboardLedReaderNoLock()
|
||||
if h.g0 == nil {
|
||||
return
|
||||
}
|
||||
if err := h.openKeyboardLedReaderNoLock(); err != nil {
|
||||
log.Debugf("reopen keyboard LED reader failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) ensureKeyboardLedReaderNotifierNoLock() error {
|
||||
if h.ledReaderNotifyReader != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create keyboard LED reader notifier: %w", err)
|
||||
}
|
||||
readerFD := int(reader.Fd())
|
||||
writerFD := int(writer.Fd())
|
||||
if err := unix.SetNonblock(readerFD, true); err != nil {
|
||||
_ = reader.Close()
|
||||
_ = writer.Close()
|
||||
return fmt.Errorf("set keyboard LED reader notifier read end nonblocking: %w", err)
|
||||
}
|
||||
if err := unix.SetNonblock(writerFD, true); err != nil {
|
||||
_ = reader.Close()
|
||||
_ = writer.Close()
|
||||
return fmt.Errorf("set keyboard LED reader notifier write end nonblocking: %w", err)
|
||||
}
|
||||
h.ledReaderNotifyReader = reader
|
||||
h.ledReaderNotifyWriter = writer
|
||||
h.ledReaderNotifyReadFD = readerFD
|
||||
h.ledReaderNotifyWriteFD = writerFD
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hid) notifyKeyboardLedReaderNoLock() {
|
||||
if h.ledReaderNotifyWriter == nil {
|
||||
return
|
||||
}
|
||||
_, err := unix.Write(h.ledReaderNotifyWriteFD, []byte{1})
|
||||
if err != nil && !errors.Is(err, syscall.EAGAIN) && !errors.Is(err, syscall.EWOULDBLOCK) {
|
||||
log.Debugf("notify keyboard LED reader failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForKeyboardLedReaderChange(notifierFD int) error {
|
||||
if notifierFD < 0 {
|
||||
return fmt.Errorf("keyboard LED reader notifier is nil")
|
||||
}
|
||||
_, err := unix.Poll([]unix.PollFd{{Fd: int32(notifierFD), Events: unix.POLLIN}}, -1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
drainKeyboardLedReaderNotifier(notifierFD)
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForKeyboardLedReportOrChange blocks until a HID output report arrives or
|
||||
// the reader is replaced/closed. Its bool result is true for the latter.
|
||||
func waitForKeyboardLedReportOrChange(file *os.File, notifierFD int) (bool, error) {
|
||||
if file == nil || notifierFD < 0 {
|
||||
return false, fmt.Errorf("keyboard LED reader or notifier is nil")
|
||||
}
|
||||
|
||||
fds := []unix.PollFd{
|
||||
{Fd: int32(file.Fd()), Events: unix.POLLIN},
|
||||
{Fd: int32(notifierFD), Events: unix.POLLIN},
|
||||
}
|
||||
_, err := unix.Poll(fds, -1)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if fds[1].Revents&unix.POLLIN != 0 {
|
||||
drainKeyboardLedReaderNotifier(notifierFD)
|
||||
return true, nil
|
||||
}
|
||||
if fds[0].Revents&(unix.POLLIN|unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("keyboard LED poll returned without an event")
|
||||
}
|
||||
|
||||
func drainKeyboardLedReaderNotifier(notifierFD int) {
|
||||
buf := make([]byte, 64)
|
||||
for {
|
||||
_, err := unix.Read(notifierFD, buf)
|
||||
if err != nil {
|
||||
if !errors.Is(err, syscall.EAGAIN) && !errors.Is(err, syscall.EWOULDBLOCK) {
|
||||
log.Debugf("drain keyboard LED reader notifier failed: %s", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
168
server/service/hid/leds_test.go
Normal file
168
server/service/hid/leds_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestKeyboardLedStatusFromReport(t *testing.T) {
|
||||
updatedAt := time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC)
|
||||
status := keyboardLedStatusFromReport(0x17, updatedAt)
|
||||
|
||||
if !status.NumLock || !status.CapsLock || !status.ScrollLock || !status.Known {
|
||||
t.Fatalf("unexpected parsed status: %+v", status)
|
||||
}
|
||||
if !status.UpdatedAt.Equal(updatedAt) {
|
||||
t.Fatalf("updatedAt = %s, want %s", status.UpdatedAt, updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyboardLedStatusStoreOnlyNotifiesStateChanges(t *testing.T) {
|
||||
now := time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC)
|
||||
store := newKeyboardLedStatusStore(func() time.Time { return now })
|
||||
notifications := make(chan KeyboardLedStatus, 2)
|
||||
store.Subscribe(func(status KeyboardLedStatus) {
|
||||
notifications <- status
|
||||
})
|
||||
|
||||
store.Update(0x01)
|
||||
store.Update(0x01)
|
||||
now = now.Add(time.Second)
|
||||
store.Update(0x03)
|
||||
|
||||
first := <-notifications
|
||||
second := <-notifications
|
||||
if !first.NumLock || first.CapsLock || !second.NumLock || !second.CapsLock {
|
||||
t.Fatalf("unexpected notifications: first=%+v second=%+v", first, second)
|
||||
}
|
||||
|
||||
select {
|
||||
case status := <-notifications:
|
||||
t.Fatalf("unexpected duplicate notification: %+v", status)
|
||||
default:
|
||||
}
|
||||
|
||||
if status := store.Get(); !status.UpdatedAt.Equal(now) {
|
||||
t.Fatalf("latest updatedAt = %s, want %s", status.UpdatedAt, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForKeyboardLedReaderChangeWaitsForNotification(t *testing.T) {
|
||||
h := &Hid{}
|
||||
h.kbMutex.Lock()
|
||||
if err := h.ensureKeyboardLedReaderNotifierNoLock(); err != nil {
|
||||
h.kbMutex.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifierFD := h.ledReaderNotifyReadFD
|
||||
h.kbMutex.Unlock()
|
||||
defer h.ledReaderNotifyReader.Close()
|
||||
defer h.ledReaderNotifyWriter.Close()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- waitForKeyboardLedReaderChange(notifierFD) }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("wait returned before a notification: %v", err)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
|
||||
h.kbMutex.Lock()
|
||||
h.notifyKeyboardLedReaderNoLock()
|
||||
h.kbMutex.Unlock()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("wait returned error: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("wait did not wake after a notification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForKeyboardLedReportOrChangeReturnsForReport(t *testing.T) {
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reader.Close()
|
||||
defer writer.Close()
|
||||
|
||||
h := &Hid{}
|
||||
h.kbMutex.Lock()
|
||||
if err := h.ensureKeyboardLedReaderNotifierNoLock(); err != nil {
|
||||
h.kbMutex.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifierFD := h.ledReaderNotifyReadFD
|
||||
h.kbMutex.Unlock()
|
||||
defer h.ledReaderNotifyReader.Close()
|
||||
defer h.ledReaderNotifyWriter.Close()
|
||||
|
||||
done := make(chan struct {
|
||||
changed bool
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
changed, err := waitForKeyboardLedReportOrChange(reader, notifierFD)
|
||||
done <- struct {
|
||||
changed bool
|
||||
err error
|
||||
}{changed, err}
|
||||
}()
|
||||
|
||||
if _, err := writer.Write([]byte{0x03}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case result := <-done:
|
||||
if result.err != nil || result.changed {
|
||||
t.Fatalf("result = changed=%t, err=%v; want report", result.changed, result.err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("wait did not wake for report")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReopenKeyboardLedReaderDoesNotCloseReplacement(t *testing.T) {
|
||||
oldReader, oldWriter, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer oldReader.Close()
|
||||
defer oldWriter.Close()
|
||||
replacement, replacementWriter, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer replacement.Close()
|
||||
defer replacementWriter.Close()
|
||||
|
||||
h := &Hid{g0Reader: oldReader}
|
||||
h.kbMutex.Lock()
|
||||
if err := h.ensureKeyboardLedReaderNotifierNoLock(); err != nil {
|
||||
h.kbMutex.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.g0Reader = replacement // Simulate reset/reopen replacing the reader.
|
||||
h.kbMutex.Unlock()
|
||||
defer h.ledReaderNotifyReader.Close()
|
||||
defer h.ledReaderNotifyWriter.Close()
|
||||
|
||||
h.reopenKeyboardLedReader(oldReader)
|
||||
current, _ := h.keyboardLedReaderHandles()
|
||||
if current != replacement {
|
||||
t.Fatal("stale reader close replaced the active reader")
|
||||
}
|
||||
if _, err := replacementWriter.Write([]byte{0x01}); err != nil {
|
||||
t.Fatalf("replacement reader was closed: %v", err)
|
||||
}
|
||||
buf := make([]byte, 1)
|
||||
if _, err := replacement.Read(buf); err != nil {
|
||||
t.Fatalf("replacement reader was closed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -5,59 +5,153 @@ import (
|
||||
)
|
||||
|
||||
func (h *Hid) Mouse(queue <-chan []byte) {
|
||||
h.mouse(queue, HID1, HID2)
|
||||
legacy := make(chan QueuedReport)
|
||||
go func() {
|
||||
defer close(legacy)
|
||||
for report := range queue {
|
||||
legacy <- QueuedReport{Data: report}
|
||||
}
|
||||
}()
|
||||
h.MouseReports(legacy)
|
||||
}
|
||||
|
||||
func (h *Hid) mouse(queue <-chan []byte, relativePath string, absolutePath string) {
|
||||
defer h.releaseRelativeMouse(relativePath)
|
||||
func (h *Hid) MouseReports(queue <-chan QueuedReport) {
|
||||
h.mouseReports(queue, HID1, HID2)
|
||||
}
|
||||
|
||||
func (h *Hid) mouseReports(queue <-chan QueuedReport, relativePath string, absolutePath string) {
|
||||
var execute func(func() error) error
|
||||
var resetRelativeMouse func()
|
||||
var resetAbsoluteMouse func()
|
||||
relativeButtonsActive := false
|
||||
absoluteButtonsActive := false
|
||||
var absoluteReleaseReport []byte
|
||||
absoluteReleaseReport := absoluteMouseReleaseReport(nil)
|
||||
defer func() {
|
||||
if relativeButtonsActive {
|
||||
if err := runCleanup(execute, func() error {
|
||||
return h.writeHID(h.relativeMouseDevice(relativePath), relativeMouseReleaseReport())
|
||||
}); err != nil {
|
||||
log.Errorf("release relative mouse on queue close failed: %s", err)
|
||||
} else if resetRelativeMouse != nil {
|
||||
resetRelativeMouse()
|
||||
}
|
||||
}
|
||||
if absoluteButtonsActive {
|
||||
h.releaseAbsoluteMouse(absolutePath, absoluteReleaseReport)
|
||||
if err := runCleanup(execute, func() error {
|
||||
return h.writeHID(h.absoluteMouseDevice(absolutePath), absoluteReleaseReport)
|
||||
}); err != nil {
|
||||
log.Errorf("release absolute mouse on queue close failed: %s", err)
|
||||
} else if resetAbsoluteMouse != nil {
|
||||
resetAbsoluteMouse()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for event := range queue {
|
||||
switch len(event) {
|
||||
case 4:
|
||||
if !h.writeHIDReport(h.relativeMouseDevice(relativePath), event) {
|
||||
if dropped := drainHIDQueue(queue); dropped > 0 {
|
||||
log.Debugf("dropped %d stale mouse HID reports after relative write failure", dropped)
|
||||
}
|
||||
h.releaseRelativeMouse(relativePath)
|
||||
execute = event.Execute
|
||||
resetRelativeMouse = event.ResetRelativeMouse
|
||||
resetAbsoluteMouse = event.ResetAbsoluteMouse
|
||||
|
||||
cleanupFailure := func(writeErr error) {
|
||||
log.Errorf("mouse HID write failed: %s", writeErr)
|
||||
if dropped := drainHIDQueue(queue); dropped > 0 {
|
||||
log.Debugf("dropped %d stale mouse HID reports after write failure", dropped)
|
||||
}
|
||||
case 6:
|
||||
if !h.writeHIDReport(h.absoluteMouseDevice(absolutePath), event) {
|
||||
if dropped := drainHIDQueue(queue); dropped > 0 {
|
||||
log.Debugf("dropped %d stale mouse HID reports after absolute write failure", dropped)
|
||||
}
|
||||
if absoluteButtonsActive {
|
||||
if h.releaseAbsoluteMouse(absolutePath, absoluteReleaseReport) {
|
||||
absoluteButtonsActive = false
|
||||
|
||||
if len(event.Data) == 4 && event.Data[0] != 0 {
|
||||
relativeButtonsActive = true
|
||||
}
|
||||
if len(event.Data) == 6 && event.Data[0] != 0 {
|
||||
absoluteButtonsActive = true
|
||||
absoluteReleaseReport = absoluteMouseReleaseReport(event.Data)
|
||||
}
|
||||
|
||||
if relativeButtonsActive || len(event.Data) == 4 {
|
||||
if err := runCleanup(execute, func() error {
|
||||
return h.writeHID(h.relativeMouseDevice(relativePath), relativeMouseReleaseReport())
|
||||
}); err != nil {
|
||||
log.Errorf("release relative mouse after write failure failed: %s", err)
|
||||
} else {
|
||||
relativeButtonsActive = false
|
||||
if resetRelativeMouse != nil {
|
||||
resetRelativeMouse()
|
||||
}
|
||||
} else if event[0] != 0 {
|
||||
h.releaseAbsoluteMouse(absolutePath, absoluteMouseReleaseReport(event))
|
||||
}
|
||||
}
|
||||
|
||||
if absoluteButtonsActive || len(event.Data) == 6 {
|
||||
releaseReport := absoluteReleaseReport
|
||||
if len(event.Data) == 6 {
|
||||
releaseReport = absoluteMouseReleaseReport(event.Data)
|
||||
}
|
||||
if err := runCleanup(execute, func() error {
|
||||
return h.writeHID(h.absoluteMouseDevice(absolutePath), releaseReport)
|
||||
}); err != nil {
|
||||
log.Errorf("release absolute mouse after write failure failed: %s", err)
|
||||
} else {
|
||||
absoluteButtonsActive = false
|
||||
if resetAbsoluteMouse != nil {
|
||||
resetAbsoluteMouse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
event.complete(false)
|
||||
}
|
||||
|
||||
switch len(event.Data) {
|
||||
case 4:
|
||||
if absoluteButtonsActive {
|
||||
if err := runCleanup(execute, func() error {
|
||||
return h.writeHID(h.absoluteMouseDevice(absolutePath), absoluteReleaseReport)
|
||||
}); err != nil {
|
||||
cleanupFailure(err)
|
||||
continue
|
||||
}
|
||||
absoluteButtonsActive = false
|
||||
if resetAbsoluteMouse != nil {
|
||||
resetAbsoluteMouse()
|
||||
}
|
||||
}
|
||||
|
||||
if err := event.run(func() error {
|
||||
return h.writeHID(h.relativeMouseDevice(relativePath), event.Data)
|
||||
}); err != nil {
|
||||
cleanupFailure(err)
|
||||
continue
|
||||
}
|
||||
absoluteReleaseReport = absoluteMouseReleaseReport(event)
|
||||
absoluteButtonsActive = event[0] != 0
|
||||
relativeButtonsActive = event.Data[0] != 0
|
||||
event.complete(true)
|
||||
case 6:
|
||||
if relativeButtonsActive {
|
||||
if err := runCleanup(execute, func() error {
|
||||
return h.writeHID(h.relativeMouseDevice(relativePath), relativeMouseReleaseReport())
|
||||
}); err != nil {
|
||||
cleanupFailure(err)
|
||||
continue
|
||||
}
|
||||
relativeButtonsActive = false
|
||||
if resetRelativeMouse != nil {
|
||||
resetRelativeMouse()
|
||||
}
|
||||
}
|
||||
|
||||
if err := event.run(func() error {
|
||||
return h.writeHID(h.absoluteMouseDevice(absolutePath), event.Data)
|
||||
}); err != nil {
|
||||
cleanupFailure(err)
|
||||
continue
|
||||
}
|
||||
absoluteReleaseReport = absoluteMouseReleaseReport(event.Data)
|
||||
absoluteButtonsActive = event.Data[0] != 0
|
||||
event.complete(true)
|
||||
default:
|
||||
log.Debugf("invalid mouse event: %v", event)
|
||||
event.complete(false)
|
||||
log.Debugf("invalid mouse event: %v", event.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) releaseRelativeMouse(path string) {
|
||||
h.writeHIDReport(h.relativeMouseDevice(path), relativeMouseReleaseReport())
|
||||
}
|
||||
|
||||
func (h *Hid) releaseAbsoluteMouse(path string, report []byte) bool {
|
||||
return h.writeHIDReport(h.absoluteMouseDevice(path), report)
|
||||
}
|
||||
|
||||
func relativeMouseReleaseReport() []byte {
|
||||
return []byte{0x00, 0x00, 0x00, 0x00}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/service/inputcontrol"
|
||||
)
|
||||
|
||||
type Char struct {
|
||||
@@ -19,6 +21,12 @@ type PasteReq struct {
|
||||
Langue string `form:"langue"`
|
||||
}
|
||||
|
||||
const (
|
||||
defaultPasteDelay = 30 * time.Millisecond
|
||||
maxPasteDuration = 25 * time.Second
|
||||
maxPasteContentRunes = int(maxPasteDuration / defaultPasteDelay)
|
||||
)
|
||||
|
||||
func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
|
||||
// if no language is specified → return base map
|
||||
if lang == "" {
|
||||
@@ -86,15 +94,15 @@ func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
|
||||
case "fr":
|
||||
// French AZERTY layout
|
||||
// Letters: a↔q swap, z↔w swap, m moved to physical ; position
|
||||
m['a'] = Char{0, 20} // a is at physical Q key (HID 20)
|
||||
m['a'] = Char{0, 20} // a is at physical Q key (HID 20)
|
||||
m['A'] = Char{2, 20}
|
||||
m['q'] = Char{0, 4} // q is at physical A key (HID 4)
|
||||
m['q'] = Char{0, 4} // q is at physical A key (HID 4)
|
||||
m['Q'] = Char{2, 4}
|
||||
m['z'] = Char{0, 26} // z is at physical W key (HID 26)
|
||||
m['z'] = Char{0, 26} // z is at physical W key (HID 26)
|
||||
m['Z'] = Char{2, 26}
|
||||
m['w'] = Char{0, 29} // w is at physical Z key (HID 29)
|
||||
m['w'] = Char{0, 29} // w is at physical Z key (HID 29)
|
||||
m['W'] = Char{2, 29}
|
||||
m['m'] = Char{0, 51} // m is at physical ; key (HID 51)
|
||||
m['m'] = Char{0, 51} // m is at physical ; key (HID 51)
|
||||
m['M'] = Char{2, 51}
|
||||
|
||||
// Numbers require Shift on AZERTY
|
||||
@@ -110,53 +118,53 @@ func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
|
||||
m['0'] = Char{2, 39}
|
||||
|
||||
// Unshifted number row → French/special characters
|
||||
m['&'] = Char{0, 30} // & at physical key 1
|
||||
m['\u00E9'] = Char{0, 31} // é at physical key 2
|
||||
m['"'] = Char{0, 32} // " at physical key 3
|
||||
m['\''] = Char{0, 33} // ' at physical key 4
|
||||
m['('] = Char{0, 34} // ( at physical key 5
|
||||
m['-'] = Char{0, 35} // - at physical key 6
|
||||
m['\u00E8'] = Char{0, 36} // è at physical key 7
|
||||
m['_'] = Char{0, 37} // _ at physical key 8
|
||||
m['\u00E7'] = Char{0, 38} // ç at physical key 9
|
||||
m['\u00E0'] = Char{0, 39} // à at physical key 0
|
||||
m['&'] = Char{0, 30} // & at physical key 1
|
||||
m['\u00E9'] = Char{0, 31} // é at physical key 2
|
||||
m['"'] = Char{0, 32} // " at physical key 3
|
||||
m['\''] = Char{0, 33} // ' at physical key 4
|
||||
m['('] = Char{0, 34} // ( at physical key 5
|
||||
m['-'] = Char{0, 35} // - at physical key 6
|
||||
m['\u00E8'] = Char{0, 36} // è at physical key 7
|
||||
m['_'] = Char{0, 37} // _ at physical key 8
|
||||
m['\u00E7'] = Char{0, 38} // ç at physical key 9
|
||||
m['\u00E0'] = Char{0, 39} // à at physical key 0
|
||||
|
||||
// Physical - key (HID 45) → ) on AZERTY
|
||||
m[')'] = Char{0, 45} // ) at physical - key
|
||||
m['\u00B0'] = Char{2, 45} // ° at shift+physical - key
|
||||
m[')'] = Char{0, 45} // ) at physical - key
|
||||
m['\u00B0'] = Char{2, 45} // ° at shift+physical - key
|
||||
|
||||
// Letter-row bracket/special keys
|
||||
m['^'] = Char{0, 47} // ^ (dead) at physical [ key (HID 47)
|
||||
m['\u00A8'] = Char{2, 47} // ¨ at shift+[
|
||||
m['$'] = Char{0, 48} // $ at physical ] key (HID 48)
|
||||
m['\u00A3'] = Char{2, 48} // £ at shift+]
|
||||
m['*'] = Char{0, 49} // * at physical \ key (HID 49)
|
||||
m['\u00B5'] = Char{2, 49} // µ at shift+\
|
||||
m['\u00F9'] = Char{0, 52} // ù at physical ' key (HID 52)
|
||||
m['%'] = Char{2, 52} // % at shift+'
|
||||
m['^'] = Char{0, 47} // ^ (dead) at physical [ key (HID 47)
|
||||
m['\u00A8'] = Char{2, 47} // ¨ at shift+[
|
||||
m['$'] = Char{0, 48} // $ at physical ] key (HID 48)
|
||||
m['\u00A3'] = Char{2, 48} // £ at shift+]
|
||||
m['*'] = Char{0, 49} // * at physical \ key (HID 49)
|
||||
m['\u00B5'] = Char{2, 49} // µ at shift+\
|
||||
m['\u00F9'] = Char{0, 52} // ù at physical ' key (HID 52)
|
||||
m['%'] = Char{2, 52} // % at shift+'
|
||||
|
||||
// Bottom row remappings
|
||||
m[','] = Char{0, 16} // , at physical M key (HID 16)
|
||||
m['?'] = Char{2, 16} // ? at shift+physical M
|
||||
m[';'] = Char{0, 54} // ; at physical , key (HID 54)
|
||||
m['.'] = Char{2, 54} // . at shift+physical ,
|
||||
m[':'] = Char{0, 55} // : at physical . key (HID 55)
|
||||
m['/'] = Char{2, 55} // / at shift+physical .
|
||||
m['!'] = Char{0, 56} // ! at physical / key (HID 56)
|
||||
m['\u00A7'] = Char{2, 56} // § at shift+physical /
|
||||
m[','] = Char{0, 16} // , at physical M key (HID 16)
|
||||
m['?'] = Char{2, 16} // ? at shift+physical M
|
||||
m[';'] = Char{0, 54} // ; at physical , key (HID 54)
|
||||
m['.'] = Char{2, 54} // . at shift+physical ,
|
||||
m[':'] = Char{0, 55} // : at physical . key (HID 55)
|
||||
m['/'] = Char{2, 55} // / at shift+physical .
|
||||
m['!'] = Char{0, 56} // ! at physical / key (HID 56)
|
||||
m['\u00A7'] = Char{2, 56} // § at shift+physical /
|
||||
|
||||
// AltGr combinations
|
||||
m['~'] = Char{0x40, 31} // AltGr+2
|
||||
m['#'] = Char{0x40, 32} // AltGr+3
|
||||
m['{'] = Char{0x40, 33} // AltGr+4
|
||||
m['['] = Char{0x40, 34} // AltGr+5
|
||||
m['|'] = Char{0x40, 35} // AltGr+6
|
||||
m['`'] = Char{0x40, 36} // AltGr+7
|
||||
m['\\'] = Char{0x40, 37} // AltGr+8
|
||||
m[']'] = Char{0x40, 45} // AltGr+physical -
|
||||
m['}'] = Char{0x40, 46} // AltGr+=
|
||||
m['@'] = Char{0x40, 39} // AltGr+0
|
||||
m['\u20AC'] = Char{0x40, 8} // € AltGr+E
|
||||
m['~'] = Char{0x40, 31} // AltGr+2
|
||||
m['#'] = Char{0x40, 32} // AltGr+3
|
||||
m['{'] = Char{0x40, 33} // AltGr+4
|
||||
m['['] = Char{0x40, 34} // AltGr+5
|
||||
m['|'] = Char{0x40, 35} // AltGr+6
|
||||
m['`'] = Char{0x40, 36} // AltGr+7
|
||||
m['\\'] = Char{0x40, 37} // AltGr+8
|
||||
m[']'] = Char{0x40, 45} // AltGr+physical -
|
||||
m['}'] = Char{0x40, 46} // AltGr+=
|
||||
m['@'] = Char{0x40, 39} // AltGr+0
|
||||
m['\u20AC'] = Char{0x40, 8} // € AltGr+E
|
||||
|
||||
}
|
||||
return m
|
||||
@@ -171,16 +179,44 @@ func (s *Service) Paste(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Content) > 1024 {
|
||||
contentRunes := []rune(req.Content)
|
||||
if len(contentRunes) > maxPasteContentRunes {
|
||||
rsp.ErrRsp(c, -2, "content too long")
|
||||
return
|
||||
}
|
||||
|
||||
charMapLocal := LangueSwitch(charMap, req.Langue)
|
||||
typeableRunes := 0
|
||||
for _, char := range contentRunes {
|
||||
if _, ok := charMapLocal[char]; ok {
|
||||
typeableRunes++
|
||||
}
|
||||
}
|
||||
if time.Duration(typeableRunes)*defaultPasteDelay > maxPasteDuration {
|
||||
rsp.ErrRsp(c, -2, "paste duration exceeds 25s")
|
||||
return
|
||||
}
|
||||
|
||||
keyUp := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
manual := s.newManualSession()
|
||||
defer manual.Close()
|
||||
reservation, err := manual.Reserve(c.Request.Context(), inputcontrol.ManualKeyboard, false, nil)
|
||||
if err != nil {
|
||||
log.Errorf("manual paste failed to acquire HID control: %v", err)
|
||||
rsp.ErrRsp(c, -3, "HID control is busy")
|
||||
return
|
||||
}
|
||||
|
||||
for _, char := range req.Content {
|
||||
writeKeyboardReport := func(report []byte) error {
|
||||
return manual.Execute(func() error {
|
||||
return s.hid.WriteKeyboardReport(report)
|
||||
})
|
||||
}
|
||||
|
||||
for _, char := range contentRunes {
|
||||
if err := context.Cause(c.Request.Context()); err != nil {
|
||||
break
|
||||
}
|
||||
key, ok := charMapLocal[char]
|
||||
if !ok {
|
||||
log.Debugf("unknown key '%c' (rune: %d)", char, char)
|
||||
@@ -188,14 +224,40 @@ func (s *Service) Paste(c *gin.Context) {
|
||||
}
|
||||
|
||||
keyDown := []byte{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
|
||||
hid.WriteHid0(keyDown)
|
||||
hid.WriteHid0(keyUp)
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
if err = writeKeyboardReport(keyDown); err != nil {
|
||||
break
|
||||
}
|
||||
if err = writeKeyboardReport(keyUp); err != nil {
|
||||
break
|
||||
}
|
||||
if err = sleepPasteContext(c.Request.Context(), defaultPasteDelay); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = context.Cause(c.Request.Context())
|
||||
}
|
||||
_ = writeKeyboardReport(keyUp)
|
||||
reservation.Complete(err == nil)
|
||||
if err != nil {
|
||||
log.Errorf("hid paste failed: %v", err)
|
||||
rsp.ErrRsp(c, -3, "HID paste failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("hid paste success, total %d characters processed", len(req.Content))
|
||||
log.Debugf("hid paste success, total %d characters processed", len(contentRunes))
|
||||
}
|
||||
|
||||
func sleepPasteContext(ctx context.Context, delay time.Duration) error {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return context.Cause(ctx)
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func copyMap(src map[rune]Char) map[rune]Char {
|
||||
|
||||
30
server/service/hid/queue.go
Normal file
30
server/service/hid/queue.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package hid
|
||||
|
||||
type QueuedReport struct {
|
||||
Data []byte
|
||||
Execute func(func() error) error
|
||||
Complete func(bool)
|
||||
ResetKeyboard func()
|
||||
ResetRelativeMouse func()
|
||||
ResetAbsoluteMouse func()
|
||||
}
|
||||
|
||||
func (r QueuedReport) run(write func() error) error {
|
||||
if r.Execute != nil {
|
||||
return r.Execute(write)
|
||||
}
|
||||
return write()
|
||||
}
|
||||
|
||||
func (r QueuedReport) complete(success bool) {
|
||||
if r.Complete != nil {
|
||||
r.Complete(success)
|
||||
}
|
||||
}
|
||||
|
||||
func runCleanup(execute func(func() error) error, write func() error) error {
|
||||
if execute != nil {
|
||||
return execute(write)
|
||||
}
|
||||
return write()
|
||||
}
|
||||
245
server/service/hid/queue_test.go
Normal file
245
server/service/hid/queue_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMouseModeSwitchReleasesPreviousDevice(t *testing.T) {
|
||||
relativeReader, relativeWriter, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer relativeReader.Close()
|
||||
absoluteReader, absoluteWriter, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer absoluteReader.Close()
|
||||
|
||||
h := &Hid{g1: relativeWriter, g2: absoluteWriter}
|
||||
queue := make(chan QueuedReport, 2)
|
||||
completed := make(chan bool, 2)
|
||||
absoluteReset := make(chan struct{}, 1)
|
||||
absoluteDown := []byte{1, 0x34, 0x12, 0x78, 0x56, 0}
|
||||
queue <- QueuedReport{
|
||||
Data: absoluteDown,
|
||||
Complete: func(success bool) { completed <- success },
|
||||
ResetAbsoluteMouse: func() {
|
||||
absoluteReset <- struct{}{}
|
||||
},
|
||||
}
|
||||
queue <- QueuedReport{
|
||||
Data: relativeMouseReleaseReport(),
|
||||
Complete: func(success bool) { completed <- success },
|
||||
ResetAbsoluteMouse: func() { absoluteReset <- struct{}{} },
|
||||
}
|
||||
close(queue)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.mouseReports(queue, "unused-relative", "unused-absolute")
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("mouse worker did not stop")
|
||||
}
|
||||
|
||||
for range 2 {
|
||||
select {
|
||||
case success := <-completed:
|
||||
if !success {
|
||||
t.Fatal("mouse report failed")
|
||||
}
|
||||
default:
|
||||
t.Fatal("missing mouse completion")
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-absoluteReset:
|
||||
default:
|
||||
t.Fatal("absolute state was not reset during mode switch")
|
||||
}
|
||||
|
||||
absoluteData := make([]byte, 12)
|
||||
if _, err := io.ReadFull(absoluteReader, absoluteData); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(absoluteData[:6], absoluteDown) {
|
||||
t.Fatalf("absolute down report = %v", absoluteData[:6])
|
||||
}
|
||||
wantRelease := absoluteMouseReleaseReport(absoluteDown)
|
||||
if !bytes.Equal(absoluteData[6:], wantRelease) {
|
||||
t.Fatalf("absolute release report = %v, want %v", absoluteData[6:], wantRelease)
|
||||
}
|
||||
relativeData := make([]byte, 4)
|
||||
if _, err := io.ReadFull(relativeReader, relativeData); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(relativeData, relativeMouseReleaseReport()) {
|
||||
t.Fatalf("relative report = %v", relativeData)
|
||||
}
|
||||
h.Close()
|
||||
}
|
||||
|
||||
func TestKeyboardFailureCompletesAfterCleanup(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hidg0")
|
||||
closedFile, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := closedFile.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h := &Hid{g0: closedFile}
|
||||
queue := make(chan QueuedReport, 1)
|
||||
cleanupStarted := make(chan struct{})
|
||||
allowCleanup := make(chan struct{})
|
||||
cleanupFinished := make(chan struct{})
|
||||
completed := make(chan bool, 1)
|
||||
executions := 0
|
||||
queue <- QueuedReport{
|
||||
Data: []byte{0, 0, 4, 0, 0, 0, 0, 0},
|
||||
Execute: func(write func() error) error {
|
||||
executions++
|
||||
if executions == 2 {
|
||||
close(cleanupStarted)
|
||||
<-allowCleanup
|
||||
}
|
||||
err := write()
|
||||
if executions == 2 {
|
||||
close(cleanupFinished)
|
||||
}
|
||||
return err
|
||||
},
|
||||
Complete: func(success bool) { completed <- success },
|
||||
}
|
||||
close(queue)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.keyboardReports(queue, path)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-cleanupStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("cleanup did not start")
|
||||
}
|
||||
select {
|
||||
case success := <-completed:
|
||||
t.Fatalf("reservation completed before cleanup: success=%v", success)
|
||||
default:
|
||||
}
|
||||
|
||||
close(allowCleanup)
|
||||
select {
|
||||
case <-cleanupFinished:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("cleanup did not finish")
|
||||
}
|
||||
select {
|
||||
case success := <-completed:
|
||||
if success {
|
||||
t.Fatal("failed write completed successfully")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("reservation was not completed")
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("keyboard worker did not stop")
|
||||
}
|
||||
h.Close()
|
||||
}
|
||||
|
||||
func TestKeyboardFailureCompletesFalseWhenCleanupFails(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hidg0")
|
||||
closedFile, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := closedFile.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h := &Hid{g0: closedFile}
|
||||
queue := make(chan QueuedReport, 1)
|
||||
completed := make(chan bool, 1)
|
||||
queue <- QueuedReport{
|
||||
Data: []byte{0, 0, 4, 0, 0, 0, 0, 0},
|
||||
Complete: func(success bool) { completed <- success },
|
||||
}
|
||||
close(queue)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.keyboardReports(queue, filepath.Join(t.TempDir(), "missing", "hidg0"))
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case success := <-completed:
|
||||
if success {
|
||||
t.Fatal("failed keyboard write completed successfully after cleanup failed")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("reservation was not completed")
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("keyboard worker did not stop")
|
||||
}
|
||||
h.Close()
|
||||
}
|
||||
|
||||
func TestMouseFailureCompletesFalseWhenCleanupFails(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hidg1")
|
||||
closedFile, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := closedFile.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h := &Hid{g1: closedFile}
|
||||
queue := make(chan QueuedReport, 1)
|
||||
completed := make(chan bool, 1)
|
||||
queue <- QueuedReport{
|
||||
Data: []byte{1, 0, 0, 0},
|
||||
Complete: func(success bool) { completed <- success },
|
||||
}
|
||||
close(queue)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.mouseReports(queue, filepath.Join(t.TempDir(), "missing", "hidg1"), filepath.Join(t.TempDir(), "hidg2"))
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case success := <-completed:
|
||||
if success {
|
||||
t.Fatal("failed mouse write completed successfully after cleanup failed")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("reservation was not completed")
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("mouse worker did not stop")
|
||||
}
|
||||
h.Close()
|
||||
}
|
||||
48
server/service/hid/release.go
Normal file
48
server/service/hid/release.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type reportWriter interface {
|
||||
WriteKeyboardReport([]byte) error
|
||||
WriteRelativeMouseReport([]byte) error
|
||||
WriteAbsoluteMouseReport([]byte) error
|
||||
}
|
||||
|
||||
func ReleaseAllHIDState() error {
|
||||
return releaseAllHIDState(GetHid())
|
||||
}
|
||||
|
||||
func ReleaseAllHIDStateBestEffort() error {
|
||||
return releaseAllHIDStateBestEffort(GetHid())
|
||||
}
|
||||
|
||||
func releaseAllHIDStateBestEffort(writer reportWriter) error {
|
||||
if err := releaseAllHIDState(writer); err != nil {
|
||||
log.Warnf("failed to release HID state during control switch: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func releaseAllHIDState(writer reportWriter) error {
|
||||
if writer == nil {
|
||||
return fmt.Errorf("HID writer is unavailable")
|
||||
}
|
||||
|
||||
var errs []error
|
||||
if err := writer.WriteKeyboardReport(keyboardReleaseReport()); err != nil {
|
||||
errs = append(errs, fmt.Errorf("release keyboard: %w", err))
|
||||
}
|
||||
if err := writer.WriteRelativeMouseReport(relativeMouseReleaseReport()); err != nil {
|
||||
errs = append(errs, fmt.Errorf("release relative mouse: %w", err))
|
||||
}
|
||||
if err := writer.WriteAbsoluteMouseReport(absoluteMouseReleaseReport(nil)); err != nil {
|
||||
errs = append(errs, fmt.Errorf("release absolute mouse: %w", err))
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
77
server/service/hid/release_test.go
Normal file
77
server/service/hid/release_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type releaseRecordingWriter struct {
|
||||
keyboardErr error
|
||||
relativeErr error
|
||||
absoluteErr error
|
||||
keyboard int
|
||||
relative int
|
||||
absolute int
|
||||
}
|
||||
|
||||
func (w *releaseRecordingWriter) WriteKeyboardReport([]byte) error {
|
||||
w.keyboard++
|
||||
return w.keyboardErr
|
||||
}
|
||||
|
||||
func (w *releaseRecordingWriter) WriteRelativeMouseReport([]byte) error {
|
||||
w.relative++
|
||||
return w.relativeErr
|
||||
}
|
||||
|
||||
func (w *releaseRecordingWriter) WriteAbsoluteMouseReport([]byte) error {
|
||||
w.absolute++
|
||||
return w.absoluteErr
|
||||
}
|
||||
|
||||
func TestReleaseAllHIDStateAttemptsEveryDevice(t *testing.T) {
|
||||
writer := &releaseRecordingWriter{
|
||||
keyboardErr: errors.New("keyboard failed"),
|
||||
relativeErr: errors.New("relative failed"),
|
||||
absoluteErr: errors.New("absolute failed"),
|
||||
}
|
||||
|
||||
err := releaseAllHIDState(writer)
|
||||
if err == nil {
|
||||
t.Fatal("expected joined release error")
|
||||
}
|
||||
if writer.keyboard != 1 || writer.relative != 1 || writer.absolute != 1 {
|
||||
t.Fatalf("release attempts keyboard=%d relative=%d absolute=%d", writer.keyboard, writer.relative, writer.absolute)
|
||||
}
|
||||
for _, message := range []string{"release keyboard", "release relative mouse", "release absolute mouse"} {
|
||||
if !strings.Contains(err.Error(), message) {
|
||||
t.Fatalf("error %q missing %q", err, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseAllHIDStateSuccess(t *testing.T) {
|
||||
writer := &releaseRecordingWriter{}
|
||||
if err := releaseAllHIDState(writer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if writer.keyboard != 1 || writer.relative != 1 || writer.absolute != 1 {
|
||||
t.Fatalf("release attempts keyboard=%d relative=%d absolute=%d", writer.keyboard, writer.relative, writer.absolute)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseAllHIDStateBestEffortSuppressesErrors(t *testing.T) {
|
||||
writer := &releaseRecordingWriter{
|
||||
keyboardErr: errors.New("keyboard failed"),
|
||||
relativeErr: errors.New("relative failed"),
|
||||
absoluteErr: errors.New("absolute failed"),
|
||||
}
|
||||
|
||||
if err := releaseAllHIDStateBestEffort(writer); err != nil {
|
||||
t.Fatalf("best-effort release error = %v, want nil", err)
|
||||
}
|
||||
if writer.keyboard != 1 || writer.relative != 1 || writer.absolute != 1 {
|
||||
t.Fatalf("release attempts keyboard=%d relative=%d absolute=%d", writer.keyboard, writer.relative, writer.absolute)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,24 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/inputcontrol"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
hid *Hid
|
||||
hid *Hid
|
||||
control *controlmode.Manager
|
||||
coordinator *inputcontrol.Coordinator
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{
|
||||
hid: GetHid(),
|
||||
hid: GetHid(),
|
||||
control: controlmode.GetManager(),
|
||||
coordinator: inputcontrol.GetCoordinator(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) newManualSession() *inputcontrol.ManualSession {
|
||||
return inputcontrol.NewManualSession(s.control, s.coordinator)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/proto"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -10,6 +9,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/service/inputcontrol"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -33,7 +35,7 @@ var modeMap = map[string]string{
|
||||
func (s *Service) GetHidMode(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
mode, err := getHidMode()
|
||||
mode, err := GetMode()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, "get HID mode failed")
|
||||
return
|
||||
@@ -45,6 +47,23 @@ func (s *Service) GetHidMode(c *gin.Context) {
|
||||
log.Debugf("get hid mode: %s", mode)
|
||||
}
|
||||
|
||||
func (s *Service) GetKeyboardLedStatus(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
status := GetKeyboardLedStatus()
|
||||
updatedAt := ""
|
||||
if !status.UpdatedAt.IsZero() {
|
||||
updatedAt = status.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetKeyboardLedStatusRsp{
|
||||
NumLock: status.NumLock,
|
||||
CapsLock: status.CapsLock,
|
||||
ScrollLock: status.ScrollLock,
|
||||
Known: status.Known,
|
||||
UpdatedAt: updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) SetHidMode(c *gin.Context) {
|
||||
var req proto.SetHidModeReq
|
||||
var rsp proto.Response
|
||||
@@ -58,7 +77,7 @@ func (s *Service) SetHidMode(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if mode, _ := getHidMode(); req.Mode == mode {
|
||||
if mode, _ := GetMode(); req.Mode == mode {
|
||||
rsp.OkRsp(c)
|
||||
return
|
||||
}
|
||||
@@ -91,7 +110,17 @@ func (s *Service) SetHidMode(c *gin.Context) {
|
||||
func (s *Service) ResetHid(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
if err := ResetUSBPHY(); err != nil {
|
||||
manual := s.newManualSession()
|
||||
defer manual.Close()
|
||||
reservation, err := manual.Reserve(c.Request.Context(), inputcontrol.ManualRelativeMouse, false, nil)
|
||||
if err != nil {
|
||||
log.Errorf("failed to acquire manual control for HID reset: %v", err)
|
||||
rsp.ErrRsp(c, -1, "HID control is busy")
|
||||
return
|
||||
}
|
||||
err = manual.Execute(ResetUSBPHY)
|
||||
reservation.Complete(err == nil)
|
||||
if err != nil {
|
||||
log.Errorf("failed to reset hid: %v", err)
|
||||
rsp.ErrRsp(c, -1, "failed to reset hid")
|
||||
return
|
||||
@@ -194,7 +223,7 @@ func copyModeFile(srcScript string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getHidMode() (string, error) {
|
||||
func GetMode() (string, error) {
|
||||
data, err := os.ReadFile(ModeFlag)
|
||||
if err != nil {
|
||||
log.Errorf("failed to read %s: %s", ModeFlag, err)
|
||||
|
||||
462
server/service/inputcontrol/coordinator.go
Normal file
462
server/service/inputcontrol/coordinator.go
Normal file
@@ -0,0 +1,462 @@
|
||||
package inputcontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMCPBusy = errors.New("MCP remote control is busy")
|
||||
ErrManualControlActive = errors.New("manual control is active")
|
||||
ErrManualInputBlocked = errors.New("manual input is blocked")
|
||||
ErrManualPreempted = errors.New("MCP operation was preempted by manual input")
|
||||
ErrMCPModeChanged = errors.New("MCP control mode changed")
|
||||
)
|
||||
|
||||
const defaultManualCooldown = 2 * time.Second
|
||||
|
||||
type OperationKind uint8
|
||||
|
||||
const (
|
||||
OperationHID OperationKind = iota
|
||||
OperationReadOnly
|
||||
)
|
||||
|
||||
type ManualReportKind uint8
|
||||
|
||||
const (
|
||||
ManualKeyboard ManualReportKind = iota
|
||||
ManualRelativeMouse
|
||||
ManualAbsoluteMouse
|
||||
)
|
||||
|
||||
type activeOperation struct {
|
||||
id uint64
|
||||
kind OperationKind
|
||||
cancel context.CancelCauseFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
type Coordinator struct {
|
||||
mu sync.Mutex
|
||||
manualWriteMu sync.Mutex
|
||||
active *activeOperation
|
||||
nextID uint64
|
||||
manualSessions int
|
||||
manualUntil time.Time
|
||||
manualCooldown time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type ManualSession struct {
|
||||
coordinator *Coordinator
|
||||
control *controlmode.Manager
|
||||
|
||||
mu sync.Mutex
|
||||
active bool
|
||||
closed bool
|
||||
generation uint64
|
||||
pending int
|
||||
keyboardHeld bool
|
||||
relativeMouseHeld bool
|
||||
absoluteMouseHeld bool
|
||||
cooldownOnIdle bool
|
||||
releaseControl func()
|
||||
}
|
||||
|
||||
type ManualReservation struct {
|
||||
once sync.Once
|
||||
session *ManualSession
|
||||
generation uint64
|
||||
kind ManualReportKind
|
||||
held bool
|
||||
startCooldown bool
|
||||
}
|
||||
|
||||
var defaultCoordinator = newCoordinator(defaultManualCooldown, time.Now)
|
||||
|
||||
func newCoordinator(cooldown time.Duration, now func() time.Time) *Coordinator {
|
||||
if cooldown < 0 {
|
||||
cooldown = 0
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Coordinator{manualCooldown: cooldown, now: now}
|
||||
}
|
||||
|
||||
func GetCoordinator() *Coordinator {
|
||||
return defaultCoordinator
|
||||
}
|
||||
|
||||
func NewManualSession(control *controlmode.Manager, coordinator *Coordinator) *ManualSession {
|
||||
if control == nil {
|
||||
control = controlmode.GetManager()
|
||||
}
|
||||
if coordinator == nil {
|
||||
coordinator = GetCoordinator()
|
||||
}
|
||||
return &ManualSession{control: control, coordinator: coordinator}
|
||||
}
|
||||
|
||||
func (c *Coordinator) BeginMCP(parent context.Context, kind OperationKind) (context.Context, func(), error) {
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.active != nil {
|
||||
return nil, nil, ErrMCPBusy
|
||||
}
|
||||
if kind == OperationHID && (c.manualSessions > 0 || c.currentTime().Before(c.manualUntil)) {
|
||||
return nil, nil, ErrManualControlActive
|
||||
}
|
||||
|
||||
c.nextID++
|
||||
id := c.nextID
|
||||
ctx, cancel := context.WithCancelCause(parent)
|
||||
operation := &activeOperation{
|
||||
id: id,
|
||||
kind: kind,
|
||||
cancel: cancel,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
c.active = operation
|
||||
|
||||
var once sync.Once
|
||||
release := func() {
|
||||
once.Do(func() {
|
||||
cancel(context.Canceled)
|
||||
c.mu.Lock()
|
||||
if c.active != nil && c.active.id == id {
|
||||
c.active = nil
|
||||
close(operation.done)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
})
|
||||
}
|
||||
return ctx, release, nil
|
||||
}
|
||||
|
||||
// BeginBackground reserves the HID lane for a best-effort background action,
|
||||
// such as the mouse jiggler. Callers should skip the action when it returns a
|
||||
// busy error rather than delaying manual or MCP input.
|
||||
func (c *Coordinator) BeginBackground(parent context.Context) (context.Context, func(), error) {
|
||||
return c.BeginMCP(parent, OperationHID)
|
||||
}
|
||||
|
||||
func (c *Coordinator) CancelMCP() {
|
||||
c.CancelMCPWithCause(ErrMCPModeChanged)
|
||||
}
|
||||
|
||||
func (c *Coordinator) CancelMCPWithCause(cause error) {
|
||||
if cause == nil {
|
||||
cause = context.Canceled
|
||||
}
|
||||
c.mu.Lock()
|
||||
operation := c.active
|
||||
c.mu.Unlock()
|
||||
if operation != nil {
|
||||
operation.cancel(cause)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Coordinator) beginManual(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.manualSessions++
|
||||
operation := c.active
|
||||
if operation != nil && operation.kind == OperationHID {
|
||||
operation.cancel(ErrManualPreempted)
|
||||
} else {
|
||||
operation = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if operation == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-operation.done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
c.endManual(false)
|
||||
return context.Cause(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Coordinator) endManual(startCooldown bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.manualSessions > 0 {
|
||||
c.manualSessions--
|
||||
}
|
||||
if startCooldown && c.manualSessions == 0 {
|
||||
c.manualUntil = c.currentTime().Add(c.cooldown())
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Coordinator) currentTime() time.Time {
|
||||
if c.now == nil {
|
||||
return time.Now()
|
||||
}
|
||||
return c.now()
|
||||
}
|
||||
|
||||
func (c *Coordinator) cooldown() time.Duration {
|
||||
if c.manualCooldown <= 0 {
|
||||
return defaultManualCooldown
|
||||
}
|
||||
return c.manualCooldown
|
||||
}
|
||||
|
||||
func (c *Coordinator) executeManual(write func() error) error {
|
||||
if write == nil {
|
||||
return nil
|
||||
}
|
||||
c.manualWriteMu.Lock()
|
||||
defer c.manualWriteMu.Unlock()
|
||||
return write()
|
||||
}
|
||||
|
||||
func (s *ManualSession) Reserve(
|
||||
ctx context.Context,
|
||||
kind ManualReportKind,
|
||||
held bool,
|
||||
allow func(controlmode.Mode) bool,
|
||||
) (*ManualReservation, error) {
|
||||
return s.reserve(ctx, kind, held, true, allow)
|
||||
}
|
||||
|
||||
func (s *ManualSession) ReserveWithCooldown(
|
||||
ctx context.Context,
|
||||
kind ManualReportKind,
|
||||
held bool,
|
||||
startCooldown bool,
|
||||
allow func(controlmode.Mode) bool,
|
||||
) (*ManualReservation, error) {
|
||||
return s.reserve(ctx, kind, held, startCooldown, allow)
|
||||
}
|
||||
|
||||
func (s *ManualSession) reserve(
|
||||
ctx context.Context,
|
||||
kind ManualReportKind,
|
||||
held bool,
|
||||
startCooldown bool,
|
||||
allow func(controlmode.Mode) bool,
|
||||
) (*ManualReservation, error) {
|
||||
if s == nil || s.coordinator == nil || s.control == nil {
|
||||
return nil, fmt.Errorf("manual input coordinator is unavailable")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return nil, fmt.Errorf("manual input session is closed")
|
||||
}
|
||||
if s.active {
|
||||
status, err := s.control.Status()
|
||||
if err != nil {
|
||||
s.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
allowed := !status.Transitioning && (allow == nil || allow(status.Mode))
|
||||
if !allowed && !s.isReleaseReportLocked(kind, held) {
|
||||
s.mu.Unlock()
|
||||
return nil, ErrManualInputBlocked
|
||||
}
|
||||
s.pending++
|
||||
startCooldown = startCooldown || s.isReleaseReportLocked(kind, held)
|
||||
reservation := &ManualReservation{
|
||||
session: s, generation: s.generation, kind: kind, held: held, startCooldown: startCooldown,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return reservation, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
status, releaseControl, err := s.control.AcquireStable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if allow != nil && !allow(status.Mode) {
|
||||
releaseControl()
|
||||
return nil, ErrManualInputBlocked
|
||||
}
|
||||
if err := s.coordinator.beginManual(ctx); err != nil {
|
||||
releaseControl()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
s.coordinator.endManual(false)
|
||||
releaseControl()
|
||||
return nil, fmt.Errorf("manual input session is closed")
|
||||
}
|
||||
if s.active {
|
||||
// Reserve calls are serialized for a WebSocket client, but keep this path
|
||||
// safe for callers that share a session concurrently.
|
||||
s.pending++
|
||||
startCooldown = startCooldown || s.isReleaseReportLocked(kind, held)
|
||||
reservation := &ManualReservation{
|
||||
session: s, generation: s.generation, kind: kind, held: held, startCooldown: startCooldown,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.coordinator.endManual(false)
|
||||
releaseControl()
|
||||
return reservation, nil
|
||||
}
|
||||
|
||||
s.active = true
|
||||
s.generation++
|
||||
s.pending = 1
|
||||
s.releaseControl = releaseControl
|
||||
reservation := &ManualReservation{
|
||||
session: s, generation: s.generation, kind: kind, held: held, startCooldown: startCooldown,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return reservation, nil
|
||||
}
|
||||
|
||||
func (s *ManualSession) isReleaseReportLocked(kind ManualReportKind, held bool) bool {
|
||||
if held {
|
||||
return false
|
||||
}
|
||||
switch kind {
|
||||
case ManualKeyboard:
|
||||
return s.keyboardHeld
|
||||
case ManualRelativeMouse, ManualAbsoluteMouse:
|
||||
return s.relativeMouseHeld || s.absoluteMouseHeld
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ManualReservation) Complete(success bool) {
|
||||
if r == nil || r.session == nil {
|
||||
return
|
||||
}
|
||||
r.once.Do(func() {
|
||||
r.session.complete(r.generation, r.kind, r.held, success, r.startCooldown)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ManualSession) Execute(write func() error) error {
|
||||
if s == nil || s.coordinator == nil {
|
||||
return fmt.Errorf("manual input coordinator is unavailable")
|
||||
}
|
||||
return s.coordinator.executeManual(write)
|
||||
}
|
||||
|
||||
func (s *ManualSession) Reset(kind ManualReportKind) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if !s.active {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
switch kind {
|
||||
case ManualKeyboard:
|
||||
s.keyboardHeld = false
|
||||
case ManualRelativeMouse:
|
||||
s.relativeMouseHeld = false
|
||||
case ManualAbsoluteMouse:
|
||||
s.absoluteMouseHeld = false
|
||||
}
|
||||
releaseControl, end, _ := s.finishIfIdleLocked()
|
||||
s.mu.Unlock()
|
||||
s.finish(releaseControl, end, true)
|
||||
}
|
||||
|
||||
func (s *ManualSession) Close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.closed = true
|
||||
s.pending = 0
|
||||
s.keyboardHeld = false
|
||||
s.relativeMouseHeld = false
|
||||
s.absoluteMouseHeld = false
|
||||
s.cooldownOnIdle = false
|
||||
releaseControl := s.releaseControl
|
||||
end := s.active
|
||||
s.active = false
|
||||
s.releaseControl = nil
|
||||
s.generation++
|
||||
s.mu.Unlock()
|
||||
s.finish(releaseControl, end, true)
|
||||
}
|
||||
|
||||
func (s *ManualSession) complete(generation uint64, kind ManualReportKind, held bool, success bool, startCooldown bool) {
|
||||
s.mu.Lock()
|
||||
if !s.active || generation != s.generation {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if s.pending > 0 {
|
||||
s.pending--
|
||||
}
|
||||
if startCooldown {
|
||||
s.cooldownOnIdle = true
|
||||
}
|
||||
if success {
|
||||
switch kind {
|
||||
case ManualKeyboard:
|
||||
s.keyboardHeld = held
|
||||
case ManualRelativeMouse:
|
||||
s.relativeMouseHeld = held
|
||||
case ManualAbsoluteMouse:
|
||||
s.absoluteMouseHeld = held
|
||||
}
|
||||
} else {
|
||||
s.keyboardHeld = false
|
||||
s.relativeMouseHeld = false
|
||||
s.absoluteMouseHeld = false
|
||||
}
|
||||
releaseControl, end, startCooldown := s.finishIfIdleLocked()
|
||||
s.mu.Unlock()
|
||||
s.finish(releaseControl, end, startCooldown)
|
||||
}
|
||||
|
||||
func (s *ManualSession) finishIfIdleLocked() (func(), bool, bool) {
|
||||
if !s.active || s.pending > 0 || s.keyboardHeld || s.relativeMouseHeld || s.absoluteMouseHeld {
|
||||
return nil, false, false
|
||||
}
|
||||
releaseControl := s.releaseControl
|
||||
startCooldown := s.cooldownOnIdle
|
||||
s.active = false
|
||||
s.releaseControl = nil
|
||||
s.cooldownOnIdle = false
|
||||
return releaseControl, true, startCooldown
|
||||
}
|
||||
|
||||
func (s *ManualSession) finish(releaseControl func(), end bool, startCooldown bool) {
|
||||
if !end {
|
||||
return
|
||||
}
|
||||
if releaseControl != nil {
|
||||
releaseControl()
|
||||
}
|
||||
s.coordinator.endManual(startCooldown)
|
||||
}
|
||||
474
server/service/inputcontrol/coordinator_test.go
Normal file
474
server/service/inputcontrol/coordinator_test.go
Normal file
@@ -0,0 +1,474 @@
|
||||
package inputcontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
)
|
||||
|
||||
func TestCoordinatorRejectsConcurrentMCP(t *testing.T) {
|
||||
coordinator := newCoordinator(defaultManualCooldown, time.Now)
|
||||
ctx, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrMCPBusy) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrMCPBusy)
|
||||
}
|
||||
coordinator.CancelMCP()
|
||||
if err := context.Cause(ctx); !errors.Is(err, ErrMCPModeChanged) {
|
||||
t.Fatalf("context cause = %v, want %v", err, ErrMCPModeChanged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinatorReleaseAllowsNextMCP(t *testing.T) {
|
||||
coordinator := newCoordinator(defaultManualCooldown, time.Now)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release()
|
||||
|
||||
_, nextRelease, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nextRelease()
|
||||
}
|
||||
|
||||
func TestManualInputPreemptsMCPWithoutSwitchingMode(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
coordinator := newCoordinator(2*time.Second, func() time.Time { return now })
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
operationCtx, releaseOperation, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
releaseMode, err := control.Acquire(controlmode.ModeMCP)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
manual := NewManualSession(control, coordinator)
|
||||
type result struct {
|
||||
reservation *ManualReservation
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
reservation, err := manual.Reserve(context.Background(), ManualKeyboard, false, nil)
|
||||
done <- result{reservation: reservation, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-operationCtx.Done():
|
||||
if cause := context.Cause(operationCtx); !errors.Is(cause, ErrManualPreempted) {
|
||||
t.Fatalf("context cause = %v, want %v", cause, ErrManualPreempted)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("manual input did not preempt active MCP operation")
|
||||
}
|
||||
releaseOperation()
|
||||
releaseMode()
|
||||
|
||||
var reservation *ManualReservation
|
||||
select {
|
||||
case got := <-done:
|
||||
if got.err != nil {
|
||||
t.Fatal(got.err)
|
||||
}
|
||||
reservation = got.reservation
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("manual input did not acquire control")
|
||||
}
|
||||
if got := control.Current(); got != controlmode.ModeMCP {
|
||||
t.Fatalf("mode = %q, want MCP to remain enabled", got)
|
||||
}
|
||||
|
||||
reservation.Complete(true)
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
now = now.Add(3 * time.Second)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatalf("MCP did not resume after cooldown: %v", err)
|
||||
}
|
||||
release()
|
||||
manual.Close()
|
||||
}
|
||||
|
||||
func TestHeldManualInputBlocksMCPUntilReleaseAndCooldown(t *testing.T) {
|
||||
now := time.Unix(200, 0)
|
||||
coordinator := newCoordinator(time.Second, func() time.Time { return now })
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
down, err := manual.Reserve(context.Background(), ManualKeyboard, true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
down.Complete(true)
|
||||
now = now.Add(10 * time.Second)
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("held input error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
|
||||
up, err := manual.Reserve(context.Background(), ManualKeyboard, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
up.Complete(true)
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
now = now.Add(2 * time.Second)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
func TestFailedHeldManualInputDoesNotRemainHeld(t *testing.T) {
|
||||
now := time.Unix(250, 0)
|
||||
coordinator := newCoordinator(time.Second, func() time.Time { return now })
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
down, err := manual.Reserve(context.Background(), ManualKeyboard, true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
down.Complete(false)
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
now = now.Add(2 * time.Second)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed held input remained active after cooldown: %v", err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
func TestFailedReleaseReportClearsHeldManualInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
kind ManualReportKind
|
||||
}{
|
||||
{name: "keyboard", kind: ManualKeyboard},
|
||||
{name: "relative mouse", kind: ManualRelativeMouse},
|
||||
{name: "absolute mouse", kind: ManualAbsoluteMouse},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
now := time.Unix(260, 0)
|
||||
coordinator := newCoordinator(time.Second, func() time.Time { return now })
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
down, err := manual.Reserve(context.Background(), tc.kind, true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
down.Complete(true)
|
||||
|
||||
up, err := manual.Reserve(context.Background(), tc.kind, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
up.Complete(false)
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
now = now.Add(2 * time.Second)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed release report left held input active: %v", err)
|
||||
}
|
||||
release()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointerMoveWithoutButtonsDoesNotStartCooldown(t *testing.T) {
|
||||
coordinator := newCoordinator(time.Second, time.Now)
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
move, err := manual.ReserveWithCooldown(context.Background(), ManualAbsoluteMouse, false, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
move.Complete(true)
|
||||
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatalf("pointer move without buttons blocked MCP: %v", err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
func TestReleaseReportForcesCooldown(t *testing.T) {
|
||||
now := time.Unix(275, 0)
|
||||
coordinator := newCoordinator(time.Second, func() time.Time { return now })
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
down, err := manual.ReserveWithCooldown(context.Background(), ManualRelativeMouse, true, true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
down.Complete(true)
|
||||
|
||||
up, err := manual.ReserveWithCooldown(context.Background(), ManualRelativeMouse, false, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
up.Complete(true)
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
now = now.Add(2 * time.Second)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
func TestCooldownAggregatesAcrossPendingReports(t *testing.T) {
|
||||
now := time.Unix(285, 0)
|
||||
coordinator := newCoordinator(time.Second, func() time.Time { return now })
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
move, err := manual.ReserveWithCooldown(context.Background(), ManualAbsoluteMouse, false, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wheel, err := manual.ReserveWithCooldown(context.Background(), ManualAbsoluteMouse, false, true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wheel.Complete(true)
|
||||
move.Complete(true)
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
now = now.Add(2 * time.Second)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
func TestRelativeAndAbsoluteMouseHeldStateIsIndependent(t *testing.T) {
|
||||
now := time.Unix(300, 0)
|
||||
coordinator := newCoordinator(time.Second, func() time.Time { return now })
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
relativeDown, err := manual.Reserve(context.Background(), ManualRelativeMouse, true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relativeDown.Complete(true)
|
||||
|
||||
absoluteMove, err := manual.Reserve(context.Background(), ManualAbsoluteMouse, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
absoluteMove.Complete(true)
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("relative button was cleared by absolute report: %v", err)
|
||||
}
|
||||
|
||||
relativeUp, err := manual.Reserve(context.Background(), ManualRelativeMouse, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relativeUp.Complete(true)
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
now = now.Add(2 * time.Second)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
func TestAbsoluteMouseHeldStateSurvivesRelativeReport(t *testing.T) {
|
||||
coordinator := newCoordinator(defaultManualCooldown, time.Now)
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
absoluteDown, err := manual.Reserve(context.Background(), ManualAbsoluteMouse, true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
absoluteDown.Complete(true)
|
||||
|
||||
relativeMove, err := manual.Reserve(context.Background(), ManualRelativeMouse, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relativeMove.Complete(true)
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("absolute button was cleared by relative report: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockedActiveSessionStillAllowsReleaseReport(t *testing.T) {
|
||||
coordinator := newCoordinator(defaultManualCooldown, time.Now)
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
down, err := manual.Reserve(context.Background(), ManualKeyboard, true, func(controlmode.Mode) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
down.Complete(true)
|
||||
|
||||
if _, err := manual.Reserve(context.Background(), ManualKeyboard, true, func(controlmode.Mode) bool { return false }); !errors.Is(err, ErrManualInputBlocked) {
|
||||
t.Fatalf("new held report error = %v, want %v", err, ErrManualInputBlocked)
|
||||
}
|
||||
|
||||
up, err := manual.Reserve(context.Background(), ManualKeyboard, false, func(controlmode.Mode) bool { return false })
|
||||
if err != nil {
|
||||
t.Fatalf("release report was blocked: %v", err)
|
||||
}
|
||||
up.Complete(true)
|
||||
}
|
||||
|
||||
func TestBlockedActiveSessionFailedReleaseClearsHeldReport(t *testing.T) {
|
||||
now := time.Unix(325, 0)
|
||||
coordinator := newCoordinator(time.Second, func() time.Time { return now })
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
down, err := manual.Reserve(context.Background(), ManualKeyboard, true, func(controlmode.Mode) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
down.Complete(true)
|
||||
|
||||
if _, err := manual.Reserve(context.Background(), ManualKeyboard, true, func(controlmode.Mode) bool { return false }); !errors.Is(err, ErrManualInputBlocked) {
|
||||
t.Fatalf("new held report error = %v, want %v", err, ErrManualInputBlocked)
|
||||
}
|
||||
|
||||
up, err := manual.Reserve(context.Background(), ManualKeyboard, false, func(controlmode.Mode) bool { return false })
|
||||
if err != nil {
|
||||
t.Fatalf("release report was blocked: %v", err)
|
||||
}
|
||||
up.Complete(false)
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
now = now.Add(2 * time.Second)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed blocked release report left held input active: %v", err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
func TestReadOnlyMCPAllowedDuringManualControl(t *testing.T) {
|
||||
coordinator := newCoordinator(defaultManualCooldown, time.Now)
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
reservation, err := manual.Reserve(context.Background(), ManualRelativeMouse, true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reservation.Complete(true)
|
||||
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationReadOnly)
|
||||
if err != nil {
|
||||
t.Fatalf("read-only MCP operation was blocked by manual control: %v", err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
func TestManualWritesAreSerialized(t *testing.T) {
|
||||
coordinator := newCoordinator(defaultManualCooldown, time.Now)
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
first := NewManualSession(control, coordinator)
|
||||
second := NewManualSession(control, coordinator)
|
||||
defer first.Close()
|
||||
defer second.Close()
|
||||
|
||||
firstReservation, err := first.Reserve(context.Background(), ManualKeyboard, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondReservation, err := second.Reserve(context.Background(), ManualRelativeMouse, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entered := make(chan struct{})
|
||||
releaseFirst := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = first.Execute(func() error {
|
||||
close(entered)
|
||||
<-releaseFirst
|
||||
return nil
|
||||
})
|
||||
firstReservation.Complete(true)
|
||||
}()
|
||||
<-entered
|
||||
|
||||
secondEntered := make(chan struct{})
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = second.Execute(func() error {
|
||||
close(secondEntered)
|
||||
return nil
|
||||
})
|
||||
secondReservation.Complete(true)
|
||||
}()
|
||||
select {
|
||||
case <-secondEntered:
|
||||
t.Fatal("second manual write entered before the first completed")
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
close(releaseFirst)
|
||||
wg.Wait()
|
||||
}
|
||||
51
server/service/mcp/auth.go
Normal file
51
server/service/mcp/auth.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package mcpservice
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func APIKeyMiddleware(control *controlmode.Manager) gin.HandlerFunc {
|
||||
if control == nil {
|
||||
control = controlmode.GetManager()
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := control.Require(controlmode.ModeMCP); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "MCP service is disabled"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
log.Errorf("failed to load MCP config for authentication: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "MCP auth unavailable"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
token, ok := extractBearer(c.GetHeader("Authorization"))
|
||||
if !ok || cfg.APIKey == "" || subtle.ConstantTimeCompare([]byte(token), []byte(cfg.APIKey)) != 1 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid MCP API key"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func extractBearer(header string) (string, bool) {
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(header, prefix) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(strings.TrimPrefix(header, prefix))
|
||||
return token, token != ""
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user