Compare commits

...

6 Commits
2.5.2 ... main

Author SHA1 Message Date
梁子月
bb7194eb6c Merge pull request #982 from liangzhuohua/feat/storage-file-manager-ui
feat(storage): adopt the NanoKVM Go file-manager UI
2026-11-06 18:52:26 +08:00
916BGAI
44c420e28b fix: prevent black screen after application upgrades
Serialize service operations and finish migration before starting services.
Install the startup script atomically, remove recursive restarts, and keep
the migration marker until required changes succeed.

Claim video ownership before clearing orphan VENC/VPSS resources, always
finish recovery SYS cleanup, and retain resource state on teardown failures.
Rebuild the bundled libkvm_mmf.so with these fixes.

Validated cross-builds, ABI and recovery failure paths, plus a 2.4.3 to 2.5.3
upgrade on hardware with 1080p browser WebRTC and MJPEG playback.
2026-11-04 19:45:09 +08:00
916BGAI
4b54fc4768 refactor(video): use Sophgo SDK for H.264 and MJPEG
Use per-codec capture locks and encoder backpressure, simplify frame ownership, and follow HDMI input dimensions. Refresh prebuilt video libraries while preserving server-only builds and deployment instructions.
2026-11-04 14:57:49 +08:00
Guoguo
02242c1908 docs: add 2.5.2 and 2.5.3 changelog entries 2026-11-03 09:59:38 +08:00
watermeko
509a22f6bd feat: add multiuser permission system (#958)
* feat(auth): add fine-grained feature permissions for multi-user access

Introduce a grantable permission catalog on top of the admin/user roles:
administrators keep implicit full access, while regular users receive an
explicit permission set covering console view/control, power, media
mount/manage, terminal, device, network, update, mcp, tailscale and ai
feature groups. Media access splits along the trust boundary: mounting
library media and reading it is media.mount, while changing library
contents (uploads, deletes, file exchange with the target) is media.manage.

Server:
- store permissions on the account record; nil keeps the pre-permission
  user behavior (console view/control and power) so upgrades are seamless
- add RequirePermission middleware and map every route to its group;
  user management stays admin-only
- require console.view in the grant set a regular user ends up with,
  including when a role change alone would leave an account without it,
  since nothing in the app renders without that grant
- let a user who may mount media read the library and follow an image
  download, while starting, cancelling or deleting stays media.manage
- scope console.control to input: keyboard, mouse, paste, and the input
  region that maps pointer motion onto the target screen. Video encoding,
  quality, resolution and capture recovery need only console.view, since
  every account holds it and a watcher still has to pick a quality its
  link can carry
- websocket console drops keyboard/mouse reports for view-only sessions
- permission changes keep the login valid: TokenVersion is only bumped
  for role/enabled changes, a grant that changes a long-lived
  connection's cached state drops that channel with a dedicated 4403
  close code so the client reconnects with fresh permissions, and the
  frontend is notified over the console websocket to refresh in place;
  a rejected request also triggers a silent account refresh
- document the catalog, the media split, the input-only scope of
  console.control and the grants that reach the whole device (terminal,
  device, update, mcp, ai) in server/README.md and README_ZH.md
- warn in the account editor that mcp hands out a device-wide API key
  which keeps working after the grant is revoked, the same way the ai
  grant is flagged for reaching the device outside the other grants

Web:
- account endpoint returns effective permissions; menus, settings tabs,
  storage page actions and editors render per permission instead of the
  isAdmin flag, and the terminal and storage routes are guarded by the
  matching grant
- a session without console.control stops capturing the pointer and the
  keyboard, hides the input-region editor and its stale-region cleanup,
  and marks the console as view only, mirroring the server that drops
  those reports; the client also gates a report already in flight
- user management gains a grouped permission editor with the console-view
  grant pinned, and warning hints on the mcp and ai grants; rows use
  aligned icon actions with tooltips
- every page refreshes the account when its grants change: the console
  socket handles the auth event itself, pages without a socket re-read on
  route change and when they become visible, and the ai sidebar closes
  once the grant is gone
- the terminal, the WebRTC viewer and the console all honor the 4403
  permission-change close instead of stranding the page or the login

Localization:
- ship every string this feature adds, including the permission editor,
  the view-only badge and the mcp/ai warnings, in all 24 supported
  locales

Verified with go vet, go test for authn/middleware/service auth, tsc
--noEmit, eslint, prettier, and a key-by-key comparison of every locale
against en.ts (694 keys, none missing).

* fix(auth): keep the emulated USB devices under the device grant

The virtual disk and the RNDIS adapter toggle the emulated USB gadget in
Settings > Device: the disk hands the target machine the raw data
partition, the adapter hands it a network interface. Neither is a media
or a network operation, so both follow `device` instead of media.mount
and network.

- route /vm/device/virtual through `device` only, so no handler has to
  narrow a union to the device a request touches
- render both switches wherever the device settings are visible, and
  read the medium state, stop a transfer, or pick the disk warning only
  with the matching media grant
- the storage page offers to enable the virtual disk only to accounts
  that may toggle it; the others are told which account has to do it,
  with the new string added in every locale
- document that attaching a medium needs the virtual disk, and that
  `device` can hand out the raw data partition and a network interface

Verified with go vet, go test for authn/middleware/service auth, tsc
--noEmit, eslint, prettier, and a key-by-key comparison of every locale
against en.ts (695 keys, none missing).

* fix(auth): repair the permission editor and the refresh path

The permission editor and the code around it shipped with several defects:

- the editor kept its field values after the modal was destroyed (antd forms
  preserve values by default), so opening it for a second account showed the
  previous account's grants and saving wrote them onto that account. The
  selection now lives in component state and is seeded when the editor opens,
  so a cancelled edit is dropped as well
- a save closed the dialog even when the server refused the change, and the
  reason was replaced by a generic message. updateUser reports success now and
  the server's own refusal (an omitted console.view, an invalid role, ...) is
  shown next to the failure
- a refused virtual-disk toggle in Settings > Device only reached the console.
  A device holder without media.manage cannot cancel the transfer that holds
  /data, so that refusal is expected and now says why the switch did not move
- losing the grant behind an open settings tab left an empty panel, and a
  vanished update tab could keep the modal locked and unclosable
- a role change bumped the token version and logged the account out mid
  session, while a permission change did not. Only disabling an account
  invalidates the login now: a role change resolves to its implicit grants and
  takes the same notify-and-reconnect path as a grant edit, so a promoted or
  demoted account keeps working and simply reconnects with new capabilities
- the console offered a mouse menu whose items only shape input, and mounted
  the on-screen keyboard, for accounts that cannot send input at all
- the account rows stopped wrapping on narrow screens and the icon-only
  actions had no accessible name
- the editor groups grants by what they reach, with media.mount next to
  media.manage instead of under the console grants, and gained a title, a
  Save/Cancel pair and a localized Media section

Server side, authn.Has replaces three copies of the same lookup, the
permission-aware channel map documents that console.view is pinned, and the
HDMI reset keeps its console.view grant with the reasoning written down.
The documentation follows: a permission change no longer claims to interrupt
the video stream, HDMI reset is listed with the view operations, and only
disabling an account invalidates a login.

* feat(auth): give the service grants their own permission group

mcp, tailscale and ai reach the device on their own — an API key outside the
web session, a VPN mesh, an agent driving the local loopback interface —
instead of through the settings the remaining grants cover, so they no longer
share the Administration section with terminal, device, network and update.

The editor renders them as a Services section between Media and
Administration, and the label ships in all 24 locales.

* refactor(auth): model grants as a catalog with implied grants

Replace the media-aware flat grant list with a catalog the server serves to the
account editor: each grant carries a group, a risk level and the grants it
implies, so labels, sections and risk marks cannot drift from what the server
enforces.

Server:
- resolve implied grants before answering a request, which removes the
  route-level unions: console.control implies console.view, and both
  medium.attach and library.write imply library.read
- split media access three ways along the trust boundary: medium.attach mounts,
  ejects and writes into the shared partition the target machine reads,
  library.write changes what the library keeps, and library.read lists and
  downloads. The file endpoints serve an image and the partition through one
  path, so they admit either grant and check the medium type in the request
- stop pinning console.view: with control implying view, an account may hold no
  console access at all, and the web app lands on a page that offers the
  sections its grants open
- materialize the grant set when the account file is read, so a record written
  before fine-grained permissions keeps exactly what the user role granted, and
  nil no longer means different behavior later; the split media grants are
  translated on load
- keep grants this build does not know instead of refusing the file, so a
  downgrade cannot lock the device out
- serve the stored set to the account list and the effective closure to the
  account endpoint

Web:
- render the editor from the catalog: grouped sections, a per-grant tooltip with
  a risk marker, implied grants shown as included, and the grants that amount to
  full device control summarized in one line
- show an administrator's stored set read-only with what a demotion restores,
  and refuse to edit while the catalog cannot be loaded
- guard the terminal, storage and console routes and the in-page actions that
  were gated by the isAdmin flag by the matching grant instead
- add a landing page for an account without console access, and let the account
  list say how many grants each account holds
- ship the new strings in all 24 locales

Docs: describe the catalog, the media split, the implied grants and the
unpinned console in server/README.md and server/README_ZH.md.

* fix(web): align the settings icons with the rest of the menu

The settings icon carried a 3px top padding to compensate for the baseline gap
an inline svg leaves inside an antd Badge, which pushed it below the other menu
icons. A flex wrapper removes the gap instead, so the icon centers the way every
MenuItem does.

- center the settings modal tab icons in their 16px box instead of leaving them
  on the box baseline
- draw the account and the Tailscale tabs at 16px: both used an 18px icon inside
  a 16px box, and the Tailscale icon now takes a size prop instead of a class

* fix(web): draw CJK punctuation with a CJK face

antd components (tooltips, dialogs) and the Tailwind-styled page text used
different font stacks, so the ideographic comma and full stop could resolve to
different faces: a Latin font in antd's stack draws them above the baseline,
which made Chinese and Japanese tooltips look misaligned.

- pin U+3001 and U+3002 to a real CJK face through a @font-face with
  unicode-range, falling back to the rest of the stack when none of the local
  fonts is installed
- give the app one stack, shared by html/body and antd's font-family token

* feat(web): list administrators above regular users

An account that can change everyone else belongs at the top of the list, and the
row reads differently at a glance: administrators get a lighter card than the
regular accounts they administer.

The sort is stable, so accounts keep the order the account file lists them in
within each group.

* fix(auth): keep a granted capability reachable from the page that configures it

Two defects in the same catalog. The grants whose controls live in the
console menu could be handed out without console.view, and nothing outside
the console renders them, so an account could hold a capability no page
offered. And the catalog carried a third risk level the editor never used.

- imply console.view from power, device, network, update, mcp, tailscale
  and ai, the way console.control already did, so every grant has a page
  that can use it; terminal and the media grants keep their own pages
- collapse risk to two levels: warn now marks the three grants that amount
  to full device control (terminal, mcp, ai), while device, update and the
  grants that change settings everyone shares are plain info
- default a user-role account created without naming a grant set to what
  the user role granted before grants existed, so a script that only knows
  about roles keeps working; an explicit empty set still means empty
- document the implied console, the two levels and the create default

* fix(web): repair the account permission editor and its confirmations

The editor treated an explicitly stored grant that another selection
already implies as editable, so console.view looked free to uncheck on an
account where device settings brought it back; it also attributed the
grant to that other selection, which describes a default permission
backwards. Saving closed the dialog, and an administrator's reference set
was described by a banner above thirteen boxes it contradicted.

- lock any grant a selected grant already brings in, and only add the
  "included by" note to a grant the account does not store itself
- keep the dialog open after a save and let the footer button say Saved
  for a moment; editing again drops the confirmation
- drop the administrator banner and render the whole catalog as its
  read-only set, so a freshly created administrator no longer trips the
  empty-set warning
- render the catalog's two risk levels: warn draws the shield and the
  full-control summary, info stays a plain help mark
- ship the new Saved label, rename the summary key and retire the demote
  hint in all 24 locales, and mirror the catalog in the mocked handlers

* fix(web): honor the grants for the storage mount mode and the mouse menu

The full storage page still offered the mount-mode selector to an account
that cannot attach, where the console menu already hid it, and the mouse
menu vanished for a view-only account even though its cursor style is
local to the screen and needs no grant.

- hide the mount-mode selector on the storage page unless medium.attach,
  the way the console menu does
- keep the mouse menu for a view-only account with the cursor style as its
  only entry, instead of returning nothing

* refactor(web): use antd for the chat composer and drop the scroll-area primitive

The chat input was the last visible raw form control, and the settings
dialog and shortcut lists scrolled through a Radix primitive whose styling
does not match the scrollbars the rest of the app draws.

- send the chat message through antd's Input.TextArea; the composer's
  shape is carried by important utilities rather than a second styling
  system, and Enter-to-send plus the IME handling stay as they were
- replace the Radix scroll area with the native scroll container the other
  pages use, so the settings dialog and the shortcut lists scroll alike
- remove the component that wrapped it and the @radix-ui/react-scroll-area
  dependency

* fix(auth): close the gaps between grants and what they reach

- require library.write for a partition upload of an .iso/.img and for
  deleting a partition file or folder that holds one: every image on /data
  is a library entry, so medium.attach alone could add or remove them
- mark update as warn: an offline package is checked only against the
  checksum its uploader supplies, so the grant amounts to root
- regenerate the device-wide MCP API key when an account loses the mcp
  grant (revoked, demoted, disabled or deleted), so a copied key stops
  working with the grant
- re-read the account after a session registers, so a grant edit landing
  between authentication and registration can no longer leave a
  long-lived connection on the old grants
- return the account before and after from Store.Update under one lock,
  so concurrent edits diff against the state they actually changed
- keep stored grants this build does not know when a request replaces
  the set, instead of refusing or dropping them
- push the auth refresh event off the admin request path
- cover the catalog closure, legacy translation, unknown-grant carry-over,
  warn levels and the library guard with tests

* fix(web): follow the grant fixes in the account and tailscale pages

- show the memory and swap settings in the Tailscale menu only with
  device, which their endpoints require
- let only the latest account refresh set the state, and coalesce the
  focus and visibilitychange events that fire together on tab return
- keep the storage page console socket across auth pushes by depending
  on the grant instead of the hasPermission identity
- send only catalog grants from the permission editor; the server keeps
  stored ones it does not know
- describe the MCP key rotation in all 24 locales, add the singular
  grant count, and mirror the update risk level in the mocks

* fix(auth): check the on-disk name and report a failed key rotation

- check a partition upload against the file it actually replaces: exFAT
  folds case with its own table, so a name that is not an image in the
  request can still overwrite one on disk
- report a failed MCP key rotation to the administrator instead of
  answering OK while the old key keeps working
- re-read the account after registration only for the long-lived
  channels a grant edit can drop, so short requests do not read the
  account file twice

* feat(auth): let the administrator choose whether to rotate the MCP key

Regenerating the device-wide MCP API key disconnects every MCP client, so
rotating it on every revocation took a decision away from the person who
has to live with it. A change that takes the mcp grant away now warns
first and leaves the rotation to the administrator.

- server: rotate only when the request asks for it (rotateMcpKey in the
  update body, a query parameter on delete) and report mcpKeyRotated in
  the response; a kept key is logged as a warning
- web: before saving a grant set, changing the role, disabling or deleting
  an account that would lose the key, show a warning with a "Regenerate
  the MCP API key" checkbox, checked by default; unchecking it explains
  that a copied key stays valid, and a rotation is confirmed afterwards
- describe the choice in the mcp grant help and the server READMEs, and
  ship the new strings in all 24 locales

---------

Co-authored-by: Guoguo <i@qwq.trade>
2026-10-21 15:19:45 +08:00
watermeko
5572e1de35 feat(storage): browse a medium and exchange files with the target machine (#957)
* feat(storage): browse a medium and exchange files with the target machine

The image menu and the download page are replaced by one storage page: it
lists the media the emulated USB disk can carry, mounts and ejects them, and
browses the files inside a medium over that same gadget, so the two machines
exchange files without going through the terminal.

Mediated media:

* Add the media API (list, state, mount, eject, delete, download, files) and a
  file browser on top of it, and keep the pre-2.6 image endpoints working as a
  compatibility layer over the same code.
* Add diskfs: an image is mounted through a loop device so the kernel's exFAT
  and FAT drivers read it, and the data partition is browsed in place, because
  the emulated disk already hands that very directory to the target machine.
* Keep the one-writer policy the read-only mounts introduced: an image is
  served read-only in every mode, and the whole data partition is remounted
  read-only here while the target machine is its writer, through the same
  SetDataWritable the virtual disk switch uses. The file manager refuses those
  writes itself as well, so a request does not depend on a mount state.
* Name the mounted medium and the USB device after the image file, cut to the
  SCSI inquiry product field and the USB product string, and restore the
  defaults when the medium is released.
* Close the HID devices around a gadget re-enumeration and reopen them after,
  since mounting and a forced eject drop them.
* Report transfers through one job slot with progress, queueing behind a
  running transfer, and a free space preflight before an upload starts. Image
  uploads keep the volume descriptor and partition table check, so an unusable
  image is rejected while it streams.

The shared folder view:

* Refresh it on a timer while the target machine owns the partition. The
  target's block level writes land in this device's page cache, which the
  mounted exFAT shares, so the file data here is already current - but the
  dentry and inode caches are not, and a file the target grew keeps the size
  it was first read with. Dropping those caches and pushing a storage event
  makes the web UI follow the target machine within a second of its own
  writeback. The partition's write counter cannot drive this: it only moves
  once this side writes its dirty pages back, which is tens of seconds later
  or never.
* Send the refresh notifications from their own goroutine, so a client that
  stopped reading cannot hold up the refresh loop.

The web UI:

* Add the storage page: the medium list with mount and eject, the file browser
  with search and sorting, uploads, downloads and deletes, and transfer
  progress.
* Gate writing on the target machine owning the disk - image uploads and file
  uploads alike - instead of letting the server answer with an error the page
  has to surface, and drop the medium rows' download and delete buttons there
  too.
* Give the shared folder, .iso and .img their own glyphs, and pick the file
  icon from the extension, so a listing can be read at a glance.
* Name the partition the way the storage page does in the desktop menu as
  well, instead of showing the API's untranslated string.
* Read the mounted medium in Settings - Device from the media state, now that
  the storage API it used is gone.
* Tell an ISO 9660 image apart from a filesystem that cannot be read at all:
  the kernel of this device carries no iso9660 support (CONFIG_ISO9660_FS), so
  the contents of an ISO cannot be listed while the image itself is served to
  the target machine like any other medium.

Localization:

* Translate the new strings for every locale, keep the Settings - Device
  strings the read-only mounts added, and drop the keys the retired image menu
  and download page left behind.

Chores:

* Remove the desktop image menu, the download page and the API they used.

* fix(storage): harden media file transfers

Serialize medium deletion with gadget attachment and restore /data write access after failed mount transitions.

Use available filesystem space for upload limits, refine virtual-device and shared-file handling, and update media path and installation test coverage.

Pin an image while it is streamed out, so a writable mount cannot start between the check and the last byte; sweep stale upload temporaries from the whole data partition at startup, which the file manager itself cannot delete; and preflight URL downloads against the response size, which only the upload path did.

Show dot files in the file browser again: only the two transfer temporary prefixes stay hidden, which is what the dot filter was for.

Delete folders recursively after the web UI confirms, refusing when an attached or mounted medium lives inside the directory; accept every .iso and .img upload by name instead of sniffing volume descriptors; and show image and file transfers in one progress display at the top of the storage page.

* fix(storage): make cache drops work and close delete and gadget races

- drop_caches only accepts 1..4 on 5.10: write 4 once to silence the log,
  then 2/3. Writing 6/7 failed with EINVAL, so the shared folder never
  saw what the target machine wrote.
- Resolve a delete path to its on-disk spelling (exFAT is case-insensitive)
  before checking it against the attached and loop-mounted media, refuse
  deleting a loop-mounted image or a folder holding an upload target, and
  fail closed when the disk state cannot be read.
- GetMediaState no longer fails when the virtual disk is off and lun.0 does
  not exist; browsing works with the virtual disk off.
- An explicit enable for the virtual disk compares against the disk being
  configured, so it leaves a served image alone; the settings toggle keeps
  the #937 semantics.
- Restore /data write access after a failed gadget script or network switch.
- eject clears the LUN file before resetting ro/cdrom, so a refused eject
  does not change the type of the medium still being served.
- Mount loop images with MS_NOSYMFOLLOW and open files with O_NOFOLLOW.
- Accept UDF images as optical media.
- Only refuse deleting transfer temporaries while a transfer runs; sweep
  stale ones in the background, by exact name, and not on a read-only /data;
  hide them from listings.
- Cap URL downloads without Content-Length at the free space and verify the
  received length.
- Encode Content-Disposition filename* strictly per RFC 5987.
- Restore image_test.go and add tests for the above.

* fix(web): keep the file browser and transfers consistent on the storage page

- Tag a listing with the medium and path it belongs to, so entries of the
  previous directory are never shown or acted on under a new path.
- Poll the desktop storage menu only while it is open.
- Show a dedicated message for a checksum mismatch.
- Bring back the USB / SCP / TF card hints behind a help button.
- Pick up a transfer already running when the page opens; make uploads
  cancellable; track concurrent uploads separately; let a failed job be
  dismissed; show a success state and guard the progress display by task.
- Handle request errors that were left unhandled.
- Dedicated confirmation for deleting a folder recursively.
- Treat UDF images as optical, send credentials on the image download
  pre-check, translate the remaining Hungarian string.

---------

Co-authored-by: Guoguo <i@qwq.trade>
2026-10-19 09:44:30 +08:00
183 changed files with 17559 additions and 7049 deletions

2
.gitignore vendored
View File

@@ -36,3 +36,5 @@ kvmapp/kvm_system/kvm_system
# Release packages assembled by scripts/package.sh
/build/
kvmapp/server
kvmapp/version

View File

@@ -1,3 +1,71 @@
## 2.5.3 (2026-11-03)
### Features
* Replaced the desktop image menu and the download page with a storage page that lists the media the emulated USB disk can carry, mounts and ejects them, and browses the files inside a medium over the same gadget, so the two machines exchange files without going through the terminal
* Added uploads, downloads and deletes to the storage page, with search, sorting, folder deletion behind a confirmation, one transfer display for image and file transfers, cancellable uploads, a free space preflight and a checksum mismatch report
* Browsed only media whose filesystem this device can read, so an ISO 9660 image is still served to the target machine but cannot be listed here, and limited CD-ROM mode to ISO 9660 and UDF images
* Made the shared folder follow the target machine: while it owns the data partition the listing refreshes on a timer after dropping the dentry and inode caches, so a file the target machine wrote shows up within a second of its own writeback
* Added a fine-grained permission system on top of the `admin` and `user` roles: administrators keep implicit full access, while every other account receives an explicit set of grants covering the console, power, media, terminal, device, network, updates, MCP, Tailscale and AI features, with the groups, risk marks and implied grants shown in a permission editor; mounting a medium needs the virtual disk, and the storage page names the account that has to enable it when the current account cannot
* Added a view-only console session for an account that may watch but not send input: the pointer and keyboard capture, the mouse menu and the input region editor follow the grant, pages, menus, settings tabs and storage actions render only what the account is allowed to use, and an account without console access lands on a page offering the sections its grants open
* Changed permission handling so an open session picks up the new grants without ending the login: a change that invalidates a cached capability drops the long-lived connection with a dedicated close code so the page reconnects, and only disabling an account ends the login
* Added the choice of regenerating the device-wide MCP API key when an account loses the MCP grant, instead of rotating the key silently and disconnecting every MCP client
* Kept the pre-2.6 image endpoints working as a compatibility layer over the new media API, so existing clients and scripts keep working
* Treated UDF images, not only ISO 9660 ones, as optical media, and accepted uploads by extension (`.iso`/`.img`), leaving an unusable image to fail when it is mounted instead of while it is stored
### Bug Fixes
* Fixed HID input being lost after a medium was mounted or the gadget was otherwise reset, by reopening the HID devices with a retry instead of a single attempt that could fail while the new nodes were still coming up
* Fixed `/data` staying read-only after a failed gadget script or a network switch
* Fixed the settings menu icon sitting a few pixels below the other menu icons, and centered the dialog tab icons in their box
* Fixed Chinese and Japanese punctuation being drawn by a Latin face in tooltips and dialogs, which pushed the ideographic comma and full stop above the baseline
### UI Improvements
* Gave the shared folder, ISO and IMG entries their own glyphs and picked file icons from the extension, so a listing can be read at a glance
* Listed administrators above regular accounts and gave their rows a lighter card
* Moved the PicoClaw chat composer to an antd text area and replaced the Radix scroll area with the native scroll container, so the settings dialog and the shortcut lists scroll like the rest of the app
### Localization
* Translated every string the storage page, the permission editor, the view-only console and the MCP key rotation add for all 24 supported locales
* Removed the keys the retired image menu and download page left behind
### Security
* Mounted images are opened through loop devices that refuse symlinks, and files inside a medium are opened without following links
### Chores
* Removed the desktop image menu, the download page and their web client code
* Dropped the `@radix-ui/react-scroll-area` dependency
* Documented the permission catalog, the split between mounting media and writing the library, and the grants that reach the whole device in `server/README.md` and `server/README_ZH.md`
## 2.5.2 (2026-09-29)
### Features
* Added a one-writer policy for the emulated USB disk and the `/data` partition: `/data` is mounted read-only from boot while the virtual disk serves the raw partition to the target machine, enabling the disk remounts it read-only and verifies the state before the target machine can write it, refusing to expose the disk when the remount fails, and releasing a medium hands the raw partition back to the target machine the same way
* Added confirmation before a mounted medium and the virtual disk take the shared partition from each other, including a warning that a running image transfer is cancelled and that `/data` becomes read-only on this device
* Added cancellation of a running image transfer before the partition handover, showed the transfer state in the image list and disabled the actions that cannot run while a medium is mounted, with a hint explaining why
* Added names for the mounted medium and the emulated USB device taken from the image file, truncated to the SCSI inquiry product field and the USB product string and restored on unmount
* Added streaming validation of uploaded images: ISO 9660 and UDF volume descriptors and raw disk images (GPT, APFS, HFS+, MBR) are accepted and anything else is rejected in seconds instead of after the whole transfer, with a free space check that keeps a 128 MiB reserve before any byte is received
### Bug Fixes
* Fixed mass storage mounts staying writable from the target machine: every mounted image is now served read-only, with the flag set before the LUN is attached, so the target machine can neither write to nor format it
* Fixed the LUN flags surviving a gadget restart, which brought the shared partition back to the target machine read-only or as an optical drive after a medium had been mounted
* Fixed the virtual disk switch staying on but unchangeable while a medium was mounted; the disk now reports itself on only while the LUN really carries the `/data` partition
* Fixed the on-screen keyboard crashing the page with "Minified React error #130" as soon as it was opened, by importing the `react-simple-keyboard` component through its named export, which the CommonJS interop under Vite 8 no longer resolves from a default import
### Localization
* Translated the new virtual disk and image mounting strings for every locale
### Chores
* Added unit tests for the upload header check, the free space preflight and the image name truncation
## 2.5.1 (2026-09-09)
### Features

View File

@@ -18,7 +18,7 @@ DOCKER_RUN_BASE := docker run -e UID=$(UID) -e GID=$(GID) -v $(PWD):/home/build/
DOCKER_BUILD_ARGS := --build-arg DOCKER_UID=$(UID) --build-arg DOCKER_GID=$(GID)
# Build commands
GO_BUILD_CMD := cd /home/build/NanoKVM/server && go mod tidy && CGO_ENABLED=1 GOOS=linux GOARCH=riscv64 CC=riscv64-unknown-linux-musl-gcc CGO_CFLAGS="-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d" go build
GO_BUILD_CMD := cd /home/build/NanoKVM/server && ./build.sh
SUPPORT_BUILD_CMD := . ./home/build/MaixCDK/bin/activate && cd /home/build/NanoKVM/support/sg2002 && ./build kvm_system && ./build kvm_system add_to_kvmapp
VISION_BUILD_CMD := . ./home/build/MaixCDK/bin/activate && cd /home/build/NanoKVM/support/sg2002 && ./build kvm_vision && ./build kvm_vision add_to_kvmapp
RELEASE_BUILD_CMD := /home/build/NanoKVM/scripts/build-in-container.sh

View File

@@ -97,7 +97,7 @@ The repository ships a [Dev Container](https://containers.dev) setup (`.devconta
- Requirements: Docker plus an editor or CLI with dev container support (e.g. the VS Code "Dev Containers" extension, or `devcontainer up`).
- On creation the container prepares itself automatically: `support/sg2002/build update_lib` (resyncs MaixCDK components with the checkout) and `pnpm install` for the frontend.
- Backend: run `server/build.sh` for a release-equivalent binary (BoringCrypto + `dl_lib` RPATH). `make app` builds a plain development binary without those, so prefer `server/build.sh` whenever the result should match a release.
- Backend: inside the container, run `./build.sh` from `server/`. On the host, `make app` runs the same script in Docker. Both use the prebuilt libraries in `server/dl_lib/`, enable the BoringCrypto build option, and set the `dl_lib` RPATH.
- Support layer: `cd support/sg2002 && ./build kvm_system` (the MaixCDK virtualenv is part of the container environment).
- Frontend: `cd web && pnpm dev` (VS Code forwards port 3001 automatically; other clients may need to map it themselves); see [web/README.md](web/README.md) for pointing it at a device.
- The base image `ghcr.io/sipeed/nanokvm-builder:latest` is x86-64 only; on Apple Silicon it runs under emulation (works, but C builds are slow). You can also build the image locally first (`make builder-image`) and point the `BASE_IMAGE` build arg at it (e.g. via `build.args` in `devcontainer.json`). On Linux hosts the first container creation also takes a while: the container user is remapped to your uid, which re-chowns its home directory once.

View File

@@ -1,6 +1,9 @@
#!/bin/sh
# nanokvm Rev3.1
exec 9>/run/nanokvm-service.lock || exit 1
flock -n 9 || { echo "NanoKVM service operation already in progress" >&2; exit 1; }
wait_for_exit() {
process="$1"
timeout="$2"
@@ -34,43 +37,42 @@ stop_process() {
fi
killall -KILL "$process" 2>/dev/null || true
wait_for_exit "$process" 2 || true
wait_for_exit "$process" 2 || {
echo "Failed to stop $process" >&2
return 1
}
}
stop_services() {
# The server owns VI/VPSS. Let it release MMF before stopping kvm_system.
stop_process NanoKVM-Server
stop_process kvm_system
rm -rf /tmp/kvm_system /tmp/server
stop_process NanoKVM-Server || return 1
stop_process kvm_system || return 1
}
start_services() {
cp -r /kvmapp/kvm_system /tmp/
if pidof NanoKVM-Server >/dev/null 2>&1 && pidof kvm_system >/dev/null 2>&1; then
return 0
fi
if pidof NanoKVM-Server >/dev/null 2>&1 || pidof kvm_system >/dev/null 2>&1; then
echo "NanoKVM is partially running; use restart" >&2
return 1
fi
rm -rf /tmp/kvm_system /tmp/server || return 1
cp -r /kvmapp/kvm_system /tmp/ || return 1
if [ -f /kvmapp/kvm_new_app ]; then
touch /tmp/.nanokvm_migrating
/tmp/kvm_system/kvm_system &
# The legacy migration invokes this init script recursively and starts
# the server itself. Wait for that single migration path to finish.
migration_wait=0
while [ "$migration_wait" -lt 30 ]; do
if [ ! -f /kvmapp/kvm_new_app ] && pidof NanoKVM-Server >/dev/null 2>&1; then
rm -f /tmp/.nanokvm_migrating
return
fi
sleep 1
migration_wait=$((migration_wait + 1))
done
rm -f /tmp/.nanokvm_migrating
else
/tmp/kvm_system/kvm_system &
/tmp/kvm_system/kvm_system --migrate 9>&-
case "$?" in
0) ;;
2) sync; reboot; return "$?" ;;
*) echo "NanoKVM migration failed" >&2; return 1 ;;
esac
fi
if ! pidof NanoKVM-Server >/dev/null 2>&1; then
rm -rf /tmp/server
cp -r /kvmapp/server /tmp/
/tmp/server/NanoKVM-Server &
fi
cp -r /kvmapp/server /tmp/ || return 1
# Daemons must not inherit the service-operation lock.
/tmp/kvm_system/kvm_system 9>&- &
/tmp/server/NanoKVM-Server 9>&- &
}
case "$1" in
@@ -118,20 +120,17 @@ case "$1" in
fi
# Start services
start_services
start_services || exit 1
;;
stop)
stop_services
stop_services || exit 1
echo "OK"
;;
restart)
if [ -f /tmp/.nanokvm_migrating ]; then
exit 0
fi
stop_services
start_services
stop_services || exit 1
start_services || exit 1
sync
echo "OK"

View File

@@ -81,22 +81,27 @@ manual download and does not change the on-device `latest.json` contract.
| `server/web/` | `web/dist/` |
| `kvm_system/kvm_system` | `support/sg2002` `build kvm_system` |
| `system/tool/` | prebuilt binaries in `tools/nanokvm_update_edid/` |
| `kvm/` | default runtime state (resolution, fps, quality) |
| `kvm/` | default runtime state (input dimensions, fps, quality) |
| everything else | tracked `kvmapp/` |
Note that `kvm/` ships default runtime state, and `install.go` replaces
`/kvmapp` wholesale, so an update resets those values on the device. That is
long-standing behaviour, not something these scripts introduce.
## The two copies of libkvm.so
## Video library linking
`NanoKVM-Server` is cgo-linked against the **tracked** `server/dl_lib/libkvm.so`
(`server/common/kvm_vision.go`: `-L../dl_lib -lkvm`), but the package ships the
library the `kvm_vision` build just produced. Those are two different files, and
the tracked one can lag well behind — published releases have shipped this
mismatch for a long time.
`make app` and `server/build.sh` compile the server using the prebuilt libraries
in `server/dl_lib/`. They do not rebuild the video libraries. The cgo directive
in `server/common/kvm_vision.go` links against `libkvm.so`, which depends on
`libkvm_mmf.so`.
Because a symbol the binary imports could in principle be absent from the
shipped library, and that would only surface as a crash on a real device,
`build-in-container.sh` checks the shipped `libkvm.so` still exports every
symbol `NanoKVM-Server` actually imports from it, and fails the build otherwise.
`make vision` rebuilds the video libraries and stages them in
`kvmapp/server/dl_lib/`; `make release-build` does this as part of the full build.
After changing library interfaces, update both prebuilt files in
`server/dl_lib/` from the same build. The tracked directory also supplies the
SDK runtime libraries used by the video build and backfills libraries that
are absent from the staged directory.
`build-in-container.sh` checks that the staged `libkvm.so` exports every KVM
symbol imported by the server. See the [server instructions](../server/README.md#compile--deploy)
for manual compilation and deployment.

View File

@@ -55,13 +55,8 @@ fi
# shellcheck disable=SC1091
. "$MAIXCDK_PATH/bin/activate"
echo "::group::support: sync MaixCDK components"
# Each support build synchronizes the repository components before compiling.
cd "$NANOKVM_PATH/support/sg2002"
# The image bakes support/sg2002/additional/ into MaixCDK's components at image
# build time, and ./build only re-copies them when components/kvm is missing.
# Refresh unconditionally so a cached image never yields stale libraries.
./build update_lib
echo "::endgroup::"
echo "::group::support: build kvm_system"
./build kvm_system
@@ -100,11 +95,8 @@ if [ "$missing" -ne 0 ]; then
exit 1
fi
# NanoKVM-Server is cgo-linked against the tracked server/dl_lib/libkvm.so
# (server/common/kvm_vision.go: -L../dl_lib -lkvm), but the package ships the
# freshly built one from kvm_vision. Those are different files, so confirm the
# shipped library still exports every symbol the binary actually imports from
# it. Without this a dropped symbol only shows up as a crash on a real device.
# Confirm the shipped video libraries provide every KVM symbol imported by
# the server, including the frame ownership API.
echo "[INFO] verifying shipped libkvm.so satisfies NanoKVM-Server"
if ! command -v readelf >/dev/null 2>&1; then
echo "[WARN] readelf unavailable, skipping ABI check"
@@ -120,13 +112,10 @@ else
| sed 's/@.*//' | LC_ALL=C sort -u
}
linked_lib="$NANOKVM_PATH/server/dl_lib/libkvm.so"
shipped_lib="$NANOKVM_PATH/kvmapp/server/dl_lib/libkvm.so"
server_bin="$NANOKVM_PATH/server/NanoKVM-Server"
# Symbols the binary imports that the link-time library provides, i.e. the
# ones libkvm.so is actually responsible for at runtime.
needed=$(comm -12 <(imported "$server_bin") <(exported "$linked_lib"))
needed=$(imported "$server_bin" | awk '/^(kvmv_|free_kvmv_data$|free_all_kvmv_data$|set_h264_gop$|set_frame_detact$|set_venc_auto_recyc$)/')
if [ -z "$needed" ]; then
echo "[WARN] no libkvm symbols resolved; skipping ABI check"

View File

@@ -66,8 +66,68 @@ turn:
NanoKVM uses two device-wide roles:
- `admin`: KVM access plus user, system, network, update, storage, terminal, script, MCP, and PicoClaw administration.
- `user`: KVM video, keyboard, mouse, paste, power/reset, and Wake-on-LAN access.
- `admin`: everything below, plus user management.
- `user`: only what the account is granted.
A grant names an action on one party, and the catalog the server serves to the account editor
carries its group, its risk, and the grants it implies:
| Group | Grants |
| --- | --- |
| console (this session) | `console.view`, `console.control` |
| target machine | `power`, `medium.attach` |
| media library (this device) | `library.read`, `library.write` |
| this device | `terminal`, `device`, `network`, `update` |
| services | `mcp`, `tailscale`, `ai` |
Implied grants are declared once, in the catalog, and the server resolves them before answering any
request: `console.control` implies `console.view`, both `medium.attach` and `library.write` imply
`library.read`, and every grant whose controls live in the console menu — `power`, `device`,
`network`, `update`, `mcp`, `tailscale` and `ai` — implies `console.view` too, because no page
outside the console renders them. Consequences worth knowing:
- An account can hold no console access at all. The web app then lands on a page that offers the
sections the account may use (the media library, the terminal) instead of a console it cannot
open. `library.read` is all that media access needs, because the other two media grants imply it,
and only `terminal` and the media grants can stand on their own pages in the first place.
- The media grants split along the trust boundary. `medium.attach` mounts or ejects a medium on the
target machine and writes into the shared partition the target reads; `library.write` changes what
the library keeps (uploads, deletes, the files inside an image); `library.read` lists and downloads.
The file endpoints serve an image and the shared partition through one path, so the route admits
either grant and each request is checked against the medium type it names. Every `.iso` and
`.img` on the partition is a library entry, so uploading one through the shared partition, or
deleting a file or folder that holds one, also needs `library.write`.
- The emulated USB devices are neither media nor network: the virtual disk and the RNDIS adapter
hand the target machine the raw data partition and a network interface, so both follow `device`.
A medium can only be attached while a `device` holder has the virtual disk switched on, and the
storage page says so when it is off.
Four grants reach further than their names suggest and amount to full device control. The catalog
has two risk levels, and `warn` is reserved for them: the account editor marks those four and
names them before the grants are saved:
- `terminal` is a root shell (the same one SSH gives) and can run saved and autostart scripts.
- `mcp` hands out a device-wide API key that drives the keyboard, mouse and screenshots from
outside the web session. The key is shared, so a copy keeps working after an account loses this
grant. Whenever a change takes the grant away (revoked, demoted, disabled or deleted), the account
editor warns and offers to regenerate the key, checked by default; regenerating disconnects every
MCP client until it is given the new key, and an administrator who keeps the key accepts that a
copy stays valid. The API takes the choice as `rotateMcpKey` (a body field on update, a query
parameter on delete) and never rotates without it.
- `ai` drives the device through the local loopback interface, which the other grants do not
restrict.
- `update` installs firmware and application packages. An offline package is checked only against
the checksum its uploader supplies, so installing one is running code as root.
`device` carries no such mark, although it changes what the device does for everyone and cannot be
narrowed per account. `device` turns SSH on or off (root login, with the device
owner's password), reboots the device, hands the raw data partition or a network interface to the
target machine through the emulated USB devices, and edits the OLED, HDMI, mDNS, memory, swap and
HID settings. The editor describes it that way.
Two grants also change settings that affect everyone watching: `console.view` sets the video mode,
resolution, frame rate and quality, and resets the HDMI capture; `power` manages the Wake-on-LAN
device list. Both are described that way in the editor.
Administrators manage accounts from **Settings > Account**. Account data remains in
`/etc/kvm/pwd`; the server migrates the legacy single-account JSON format in place and writes
@@ -77,12 +137,31 @@ Users must confirm their current password when changing it themselves; administr
non-owner users from the account manager. Only the device owner can change the device owner's
password, because that password is also synchronized to the Linux root account.
All authenticated sessions are backed by the current account state. Disabling, deleting,
changing the role or password of a user invalidates that user's HTTP and real-time connections
without affecting other users. Multiple users may watch and control the KVM concurrently; input
uses the existing cooperative HID coordinator, so simultaneous input can interleave.
Video mode, quality, resolution, and MJPEG frame-detection controls remain shared KVM
operations; when several users adjust them concurrently, the latest change applies device-wide.
All authenticated sessions are backed by the current account state: every request is authorized
against the account file, so a revoked grant stops working immediately without re-issuing a login.
Disabling, deleting, or resetting the password of a user invalidates that user's HTTP and real-time
connections without affecting other users. Changing the role or the grants of an account keeps its
login valid: the account is told to refresh, and only the long-lived connections whose cached
state depends on what changed are dropped (console, video stream, terminal, AI), so those clients
reconnect under the new grants. An account that loses `console.view` is sent to the landing page
instead of a console it can no longer open. Multiple
users may watch and control the KVM concurrently; input uses the existing cooperative HID
coordinator, so simultaneous input can interleave. A user without `console.control` keeps the
video and the menu for its other grants, but the console rejects keyboard and mouse reports and
the web UI marks the session as view only.
Video mode, quality, resolution, MJPEG frame-detection controls, and resetting the HDMI capture
require only `console.view` and stay shared KVM operations; when several users adjust them
concurrently, the latest change applies device-wide. Enabling or disabling the HDMI capture and
its idle timeout are device settings. `console.control` is input only: keyboard, mouse, paste, and
the input region that maps pointer motion onto the target screen.
The account file stores an explicit grant set: a record written before fine-grained permissions
keeps exactly what the user role used to grant, and the split media grants are translated on load,
so the store never holds a missing set that would fall back to different behavior later. Grants
this build does not know are preserved and ignored, so a downgrade cannot lock the device out.
Creating a regular account without naming a grant set keeps that same meaning, so a script that only
knows about roles gets the account the user role used to create; the account editor seeds a new
regular account with `console.view` only, and an explicitly empty set really is empty.
## Compile & Deploy

View File

@@ -64,17 +64,75 @@ turn:
NanoKVM 使用两种设备级角色:
- `admin`:除 KVM 操作外,还可管理用户、系统、网络、更新、存储、终端、脚本、MCP 和 PicoClaw。
- `user`:可使用 KVM 视频、键盘、鼠标、粘贴、目标机电源/复位和网络唤醒。
- `admin`:拥有以下全部能力,并可管理用户。
- `user`:仅拥有被授予的功能权限。
每项权限都描述“对谁做什么”,服务端下发给账户编辑器的权限目录还带有分组、风险等级和蕴含关系:
| 分组 | 权限 |
| --- | --- |
| 控制台(本会话) | `console.view`、`console.control` |
| 被控机 | `power`、`medium.attach` |
| 介质库(本设备) | `library.read`、`library.write` |
| 本设备 | `terminal`、`device`、`network`、`update` |
| 服务 | `mcp`、`tailscale`、`ai` |
蕴含关系只在权限目录里声明一次,并由服务端在应答任何请求前解析:`console.control` 蕴含
`console.view`,`medium.attach` 与 `library.write` 都蕴含 `library.read`;而控制项位于控制台菜单里的
权限——`power`、`device`、`network`、`update`、`mcp`、`tailscale`、`ai`——同样蕴含 `console.view`,
因为控制台之外没有任何页面渲染它们。由此得到几个行为:
- 账户可以完全没有控制台权限。此时 Web 界面会落到一个提示页,列出该账户可用的页面
(介质库、终端),而不是打开一个它无权访问的控制台。媒体功能只需要 `library.read`,
因为另外两项媒体权限都蕴含它;也只有 `terminal` 与媒体权限能拥有控制台之外的独立页面。
- 媒体权限按信任边界拆分:`medium.attach` 在被控机上挂载/弹出介质,并写入被控机可读的共享分区;
`library.write` 修改库内容(上传、删除、镜像内的文件);`library.read` 负责列表与下载。
文件相关接口用同一路径同时服务镜像与共享分区,因此路由同时接受这两项权限之一,
再按请求指定的介质类型做校验。分区上的每个 `.iso`、`.img` 都属于介质库,因此经共享分区上传这类文件,
或删除本身是/内含这类文件的文件或文件夹,同样需要 `library.write`。
- 模拟的 USB 设备既不属于媒体也不属于网络:虚拟磁盘与 RNDIS 虚拟网卡分别把设备的原始数据分区
和一块网卡交给被控机,因此都归 `device`。只有拥有 `device` 权限的账户开启虚拟磁盘后,才能挂载
镜像;磁盘未开启时存储页面会给出提示。
以下四项权限的实际范围比名称更大,等同于完整的设备控制权;权限目录只有两个风险等级,`warn`
专用于它们,账户编辑器会做标记并在保存前按名称列出:
- `terminal`:即 root shell(与 SSH 相同),并可运行已保存的脚本和开机自启脚本。
- `mcp`:会下发设备级 API Key,可在 Web 会话之外控制键鼠并获取截图。Key 为共用,账户失去该权限后已复制的
Key 仍然有效。任何会让账户失去该权限的操作(撤销、降级、禁用或删除),账户编辑器都会警告并提供「重新生成
Key」选项(默认勾选);重新生成会断开所有 MCP 客户端直到换用新 Key,管理员选择保留则自行承担副本仍有效的风险。
API 通过 `rotateMcpKey` 传递该选择(更新时放在请求体,删除时放在查询参数),不带它就不会轮换。
- `ai`:通过本地回环接口操作设备,不受其他权限限制。
- `update`:安装固件与应用更新包。离线包只校验上传者自己提供的校验和,安装它即等于以 root 运行代码。
`device` 不做该标记:它仍在本账户被授予的设置范围内,但会改变设备对所有人生效的行为,
且无法按账户收窄。`device` 可开关 SSH(root,口令为设备所有者口令)、重启设备、通过模拟的 USB 设备把
原始数据分区或网卡交给被控机,并修改 OLED、HDMI、mDNS、内存、交换与 HID 设置。编辑器中对此同样有说明。
另有两项权限会改动影响所有观看者的设置:`console.view` 会设置视频模式、分辨率、帧率与画质,
并复位 HDMI 采集;`power` 会管理网络唤醒设备列表。编辑器中对这两项都有明确说明。
管理员可在**设置 > 账户**中管理用户。账户数据继续保存在 `/etc/kvm/pwd`;服务会原地迁移旧版单账户
JSON,并以 `0600` 权限原子写入多用户格式。沿用同一路径可保持长按 BOOT 键重置密码的现有行为。
用户自助修改密码时必须验证当前密码;管理员可在账户管理中重置非设备所有者的密码。由于设备所有者
密码还会同步至 Linux root 账户,因此只有设备所有者本人可以修改自己的密码。
所有登录会话都会校验当前账户状态。禁用、删除、修改角色或密码会立即撤销该用户的 HTTP 与实时连接,
且不会影响其他用户。多个用户可同时观看和协作控制 KVM;输入沿用现有 HID 协调器,因此同时输入可能交错。
视频模式、画质、分辨率和 MJPEG 帧检测仍属于共享 KVM 操作;多人同时调整时,以最后一次设备级修改为准。
所有登录会话都会校验当前账户状态:每个请求都按账户文件重新授权,因此被撤销的权限会立即失效,
无需重新登录。禁用、删除或重置密码会立即撤销该用户的 HTTP 与实时连接,且不会影响其他用户。
修改角色或权限不会失效登录:账户会收到刷新通知,只会断开缓存了相关状态的长期连接
(控制台、视频流、终端、AI),这些客户端随后会以新权限重连。失去 `console.view` 的账户会被
转到提示页,而不是一个无法打开的控制台。多个用户可同时观看和协作控制 KVM;输入沿用现有 HID
协调器,因此同时输入可能交错。没有 `console.control` 的用户仍可观看画面并使用其其他权限对应的菜单,
但控制台会丢弃其键鼠报文,Web 界面会将会话标记为只读。
视频模式、画质、分辨率、MJPEG 帧检测以及复位 HDMI 采集只要求 `console.view`,仍属于共享 KVM 操作;
多人同时调整时,以最后一次设备级修改为准。启用/关闭 HDMI 采集及其空闲超时则属于设备设置。
`console.control` 仅限输入:键盘、鼠标、粘贴,以及将指针位移映射到目标机画面的输入区域。
账户文件保存的是显式权限集合:细粒度权限出现之前写入的记录,在读取时会物化为当时 user 角色的
能力,拆分过的媒体权限也会被翻译成新名称,因此存储中不会留下“缺失即回退到另一套行为”的状态。
本版本不认识的权限会被原样保留并忽略,因此回滚版本不会把设备锁在门外。
新建普通账户时若不指定权限集合,含义同上:只认识角色的脚本仍会得到 user 角色过去创建出的账户;
账户编辑器新建普通账户时只预置 `console.view`,而显式传入的空集合就是真的空。
## 编译部署

322
server/authn/permissions.go Normal file
View File

@@ -0,0 +1,322 @@
package authn
import (
"fmt"
)
// Permission identifies a grantable capability. The admin role implicitly holds
// every permission; the user role only holds the permissions stored on the
// account.
//
// A permission names what it lets the account do, not the settings page it
// lives on, and every grant that changes something shared says so in the
// catalog's risk and effect metadata instead of in the label alone.
type Permission string
const (
// Console grants.
PermConsoleView Permission = "console.view"
PermConsoleControl Permission = "console.control"
// Target-machine grants.
PermPower Permission = "power"
PermMediumAttach Permission = "medium.attach"
// Media library grants.
PermLibraryRead Permission = "library.read"
PermLibraryWrite Permission = "library.write"
// Host grants.
PermTerminal Permission = "terminal"
PermDevice Permission = "device"
PermNetwork Permission = "network"
PermUpdate Permission = "update"
// Service grants.
PermMcp Permission = "mcp"
PermTailscale Permission = "tailscale"
PermAi Permission = "ai"
)
// Group names the party a grant reaches. The account editor renders one section
// per group, so the sections stay meaningful as the catalog grows.
type Group string
const (
// GroupConsole is what an account may do with the console it is watching.
GroupConsole Group = "console"
// GroupTarget is what it may do to the machine behind the console.
GroupTarget Group = "target"
// GroupLibrary is what it may do with the media library on this device.
GroupLibrary Group = "library"
// GroupHost is what it may change on this device.
GroupHost Group = "host"
// GroupServices are the add-ons that reach the device on their own.
GroupServices Group = "services"
)
// Risk states how far a grant reaches, and the catalog carries two levels: a
// plain grant, and the grants that amount to full device control, which the
// account editor marks and names before the grants are saved.
type Risk string
const (
// RiskInfo is a grant that stays within the feature it names.
RiskInfo Risk = "info"
// RiskWarn is a grant that amounts to full device control: a root shell,
// the AI loopback, an API key that drives input outside the web session, and
// a package install whose checksum the uploader supplies.
RiskWarn Risk = "warn"
)
// Descriptor is one catalog entry: the grant, the group it belongs to, the risk
// an administrator takes by granting it, and the grants it implies.
type Descriptor struct {
Permission Permission `json:"id"`
Group Group `json:"group"`
Risk Risk `json:"risk"`
Implies []Permission `json:"implies,omitempty"`
}
// Implied grants are declared once here instead of as unions at every call
// site: control without the picture is meaningless, nothing can be attached or
// changed in the library without being allowed to see it first, and the grants
// whose controls live in the console menu imply the console, because no page
// outside it renders them and a grant nothing can use is a promise the app
// cannot keep. Terminal and the media grants stand on their own pages.
var permissionCatalog = []Descriptor{
{Permission: PermConsoleView, Group: GroupConsole, Risk: RiskInfo},
{
Permission: PermConsoleControl,
Group: GroupConsole,
Risk: RiskInfo,
Implies: []Permission{PermConsoleView},
},
{
Permission: PermPower,
Group: GroupTarget,
Risk: RiskInfo,
Implies: []Permission{PermConsoleView},
},
{
Permission: PermMediumAttach,
Group: GroupTarget,
Risk: RiskInfo,
Implies: []Permission{PermLibraryRead},
},
{Permission: PermLibraryRead, Group: GroupLibrary, Risk: RiskInfo},
{
Permission: PermLibraryWrite,
Group: GroupLibrary,
Risk: RiskInfo,
Implies: []Permission{PermLibraryRead},
},
{Permission: PermTerminal, Group: GroupHost, Risk: RiskWarn},
{
Permission: PermDevice,
Group: GroupHost,
Risk: RiskInfo,
Implies: []Permission{PermConsoleView},
},
{
Permission: PermNetwork,
Group: GroupHost,
Risk: RiskInfo,
Implies: []Permission{PermConsoleView},
},
{
Permission: PermUpdate,
Group: GroupHost,
Risk: RiskWarn,
Implies: []Permission{PermConsoleView},
},
{
Permission: PermMcp,
Group: GroupServices,
Risk: RiskWarn,
Implies: []Permission{PermConsoleView},
},
{
Permission: PermTailscale,
Group: GroupServices,
Risk: RiskInfo,
Implies: []Permission{PermConsoleView},
},
{
Permission: PermAi,
Group: GroupServices,
Risk: RiskWarn,
Implies: []Permission{PermConsoleView},
},
}
// Grants of earlier releases, kept so an account file written by one of them
// keeps meaning what it meant. media.mount and media.manage split into the
// attach/read/write grants above.
const (
legacyPermMediaMount Permission = "media.mount"
legacyPermMediaManage Permission = "media.manage"
)
// Catalog returns the catalog in display order.
func Catalog() []Descriptor {
catalog := make([]Descriptor, len(permissionCatalog))
for index, descriptor := range permissionCatalog {
catalog[index] = descriptor
catalog[index].Implies = append([]Permission(nil), descriptor.Implies...)
}
return catalog
}
// PermissionIDs returns every grantable permission in catalog order.
func PermissionIDs() []Permission {
ids := make([]Permission, 0, len(permissionCatalog))
for _, descriptor := range permissionCatalog {
ids = append(ids, descriptor.Permission)
}
return ids
}
func IsValidPermission(permission Permission) bool {
for _, descriptor := range permissionCatalog {
if descriptor.Permission == permission {
return true
}
}
return false
}
// ValidatePermissions rejects unknown or duplicate permissions. It is for grant
// sets arriving in a request; the grant set stored on an account is validated by
// ValidateStoredPermissions, which tolerates ids this build does not know.
func ValidatePermissions(permissions []Permission) error {
seen := make(map[Permission]struct{}, len(permissions))
for _, permission := range permissions {
if !IsValidPermission(permission) {
return fmt.Errorf("unknown permission: %q", permission)
}
if _, exists := seen[permission]; exists {
return fmt.Errorf("duplicate permission: %q", permission)
}
seen[permission] = struct{}{}
}
return nil
}
// ValidateStoredPermissions only rejects duplicates. An account file written by
// a newer release carries grants this build cannot evaluate, and refusing to
// load it would lock the device out instead of ignoring a capability that is
// unavailable here anyway.
func ValidateStoredPermissions(permissions []Permission) error {
seen := make(map[Permission]struct{}, len(permissions))
for _, permission := range permissions {
if _, exists := seen[permission]; exists {
return fmt.Errorf("duplicate permission: %q", permission)
}
seen[permission] = struct{}{}
}
return nil
}
// Has reports whether the set contains the permission. Administrators are not
// special-cased: callers that should honor the role's implicit grants resolve
// EffectivePermissions first.
func Has(permissions []Permission, permission Permission) bool {
for _, granted := range permissions {
if granted == permission {
return true
}
}
return false
}
// Closure resolves the grants an account ends up holding: the set itself plus
// everything it implies, transitively, in catalog order.
func Closure(permissions []Permission) []Permission {
granted := closureSet(permissions)
resolved := make([]Permission, 0, len(granted))
for _, descriptor := range permissionCatalog {
if _, ok := granted[descriptor.Permission]; ok {
resolved = append(resolved, descriptor.Permission)
delete(granted, descriptor.Permission)
}
}
// Keep ids this build does not know in the order they were stored, so they
// still round-trip through the client.
for _, permission := range permissions {
if _, ok := granted[permission]; ok {
resolved = append(resolved, permission)
delete(granted, permission)
}
}
return resolved
}
func closureSet(permissions []Permission) map[Permission]struct{} {
granted := make(map[Permission]struct{}, len(permissions))
var add func(Permission)
add = func(permission Permission) {
if _, exists := granted[permission]; exists {
return
}
granted[permission] = struct{}{}
for _, descriptor := range permissionCatalog {
if descriptor.Permission != permission {
continue
}
for _, implied := range descriptor.Implies {
add(implied)
}
}
}
for _, permission := range permissions {
add(permission)
}
return granted
}
// NormalizeStoredPermissions translates the grants an earlier release wrote and
// removes duplicates, so the store holds one spelling of every capability.
// Unknown ids are preserved: a downgrade must not silently drop a grant.
func NormalizeStoredPermissions(permissions []Permission) []Permission {
normalized := make([]Permission, 0, len(permissions)+2)
appendOnce := func(permission Permission) {
if !Has(normalized, permission) {
normalized = append(normalized, permission)
}
}
for _, permission := range permissions {
switch permission {
case legacyPermMediaMount:
// Attaching a medium is what let a watcher read the library.
appendOnce(PermMediumAttach)
appendOnce(PermLibraryRead)
case legacyPermMediaManage:
appendOnce(PermLibraryRead)
appendOnce(PermLibraryWrite)
default:
appendOnce(permission)
}
}
return normalized
}
// legacyUserPermissions preserves the capabilities the user role had before
// fine-grained permissions existed: full console access and power control.
func legacyUserPermissions() []Permission {
return []Permission{PermConsoleView, PermConsoleControl, PermPower}
}
// EffectivePermissions resolves what a user can actually do: administrators
// hold the whole catalog, regular users hold their stored grants and whatever
// those imply.
func EffectivePermissions(user *User) []Permission {
if user == nil {
return nil
}
if user.Role == RoleAdmin {
return PermissionIDs()
}
return Closure(user.Permissions)
}

View File

@@ -0,0 +1,86 @@
package authn
import (
"encoding/json"
"os"
"path/filepath"
"slices"
"testing"
)
func TestClosureResolvesImpliedGrants(t *testing.T) {
cases := []struct {
grants []Permission
want []Permission
}{
{[]Permission{PermConsoleControl}, []Permission{PermConsoleView, PermConsoleControl}},
{[]Permission{PermMediumAttach}, []Permission{PermMediumAttach, PermLibraryRead}},
{[]Permission{PermLibraryWrite}, []Permission{PermLibraryRead, PermLibraryWrite}},
{[]Permission{PermTerminal}, []Permission{PermTerminal}},
{[]Permission{"future.grant", PermMcp}, []Permission{PermConsoleView, PermMcp, "future.grant"}},
}
for _, tc := range cases {
if got := Closure(tc.grants); !slices.Equal(got, tc.want) {
t.Errorf("Closure(%v) = %v, want %v", tc.grants, got, tc.want)
}
}
}
func TestNormalizeTranslatesLegacyMediaGrants(t *testing.T) {
got := NormalizeStoredPermissions([]Permission{legacyPermMediaMount, legacyPermMediaManage, PermPower})
want := []Permission{PermMediumAttach, PermLibraryRead, PermLibraryWrite, PermPower}
if !slices.Equal(got, want) {
t.Fatalf("NormalizeStoredPermissions = %v, want %v", got, want)
}
}
func TestUpdateReturnsPreviousAndKeepsUnknownGrants(t *testing.T) {
path := filepath.Join(t.TempDir(), "pwd")
store := NewStore(path)
if err := store.Create("alice", "password", RoleUser, PermConsoleView); err != nil {
t.Fatal(err)
}
// A newer release stored a grant this build does not know.
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var db database
if err = json.Unmarshal(data, &db); err != nil {
t.Fatal(err)
}
index := userIndex(db.Users, "alice")
db.Users[index].Permissions = append(db.Users[index].Permissions, "future.grant")
if data, err = json.Marshal(db); err != nil {
t.Fatal(err)
}
if err = os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
grants := []Permission{PermConsoleControl}
previous, updated, err := store.Update("admin", "alice", UserPatch{Permissions: &grants})
if err != nil {
t.Fatal(err)
}
if !slices.Equal(previous.Permissions, []Permission{PermConsoleView, "future.grant"}) {
t.Fatalf("previous permissions = %v", previous.Permissions)
}
if !slices.Equal(updated.Permissions, []Permission{PermConsoleControl, "future.grant"}) {
t.Fatalf("updated permissions = %v", updated.Permissions)
}
}
func TestOnlyFullControlGrantsWarn(t *testing.T) {
var warned []Permission
for _, descriptor := range Catalog() {
if descriptor.Risk == RiskWarn {
warned = append(warned, descriptor.Permission)
}
}
want := []Permission{PermTerminal, PermUpdate, PermMcp, PermAi}
if !slices.Equal(warned, want) {
t.Fatalf("warned grants = %v, want %v", warned, want)
}
}

View File

@@ -42,25 +42,28 @@ var (
)
type User struct {
Username string `json:"username"`
PasswordHash string `json:"password"`
Role Role `json:"role"`
Enabled bool `json:"enabled"`
TokenVersion uint64 `json:"tokenVersion"`
MustChangePassword bool `json:"mustChangePassword,omitempty"`
SystemAccount bool `json:"systemAccount,omitempty"`
Username string `json:"username"`
PasswordHash string `json:"password"`
Role Role `json:"role"`
Enabled bool `json:"enabled"`
TokenVersion uint64 `json:"tokenVersion"`
Permissions []Permission `json:"permissions"`
MustChangePassword bool `json:"mustChangePassword,omitempty"`
SystemAccount bool `json:"systemAccount,omitempty"`
}
type UserInfo struct {
Username string `json:"username"`
Role Role `json:"role"`
Enabled bool `json:"enabled"`
SystemAccount bool `json:"systemAccount,omitempty"`
Username string `json:"username"`
Role Role `json:"role"`
Enabled bool `json:"enabled"`
Permissions []Permission `json:"permissions"`
SystemAccount bool `json:"systemAccount,omitempty"`
}
type UserPatch struct {
Role *Role
Enabled *bool
Role *Role
Enabled *bool
Permissions *[]Permission
}
type database struct {
@@ -115,11 +118,16 @@ func (s *Store) List() ([]UserInfo, error) {
}
users := make([]UserInfo, 0, len(db.Users))
for _, user := range db.Users {
for index := range db.Users {
user := &db.Users[index]
// The stored grants, not the effective ones: the editor shows what an
// administrator set, and what an account implies is derived from the
// catalog instead of being written back on every save.
users = append(users, UserInfo{
Username: user.Username,
Role: user.Role,
Enabled: user.Enabled,
Permissions: clonePermissions(user.Permissions),
SystemAccount: user.SystemAccount,
})
}
@@ -192,7 +200,7 @@ func (s *Store) ValidateToken(username string, tokenVersion uint64) (*User, erro
return user, nil
}
func (s *Store) Create(username, password string, role Role) error {
func (s *Store) Create(username, password string, role Role, permissions ...Permission) error {
if err := ValidateUsername(username); err != nil {
return err
}
@@ -202,6 +210,16 @@ func (s *Store) Create(username, password string, role Role) error {
if !IsValidRole(role) {
return errors.New("invalid role")
}
// A nil set means the caller named none at all, which keeps the meaning it
// had before grants existed: an account created by a script that only knows
// about roles gets what the user role granted then. An explicit empty set is
// a decision and stays empty, so the account really holds nothing.
if permissions == nil && role == RoleUser {
permissions = legacyUserPermissions()
}
if err := ValidatePermissions(permissions); err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
@@ -227,37 +245,66 @@ func (s *Store) Create(username, password string, role Role) error {
Role: role,
Enabled: true,
TokenVersion: tokenVersion,
Permissions: NormalizeStoredPermissions(permissions),
})
return s.saveLocked(db)
}
func (s *Store) Update(actor, username string, patch UserPatch) (*User, error) {
// Update applies the patch and returns the account as it was before and after,
// both read under the store lock, so a caller comparing the two sees exactly the
// change it made even while another update runs.
func (s *Store) Update(actor, username string, patch UserPatch) (previous, updated *User, err error) {
s.mutex.Lock()
defer s.mutex.Unlock()
db, err := s.loadLocked(true)
if err != nil {
return nil, err
return nil, nil, err
}
index := userIndex(db.Users, username)
if index < 0 {
return nil, ErrUserNotFound
return nil, nil, ErrUserNotFound
}
previous = cloneUser(&db.Users[index])
user := db.Users[index]
if patch.Role != nil && !IsValidRole(*patch.Role) {
return nil, errors.New("invalid role")
return nil, nil, errors.New("invalid role")
}
// The patch is normalized before it is compared and stored, so a caller that
// still sends the split media grants does not look like a change once they
// resolve to the grants they now mean.
granted := user.Permissions
if patch.Permissions != nil {
if err := ValidatePermissions(*patch.Permissions); err != nil {
return nil, nil, err
}
granted = NormalizeStoredPermissions(*patch.Permissions)
// A request can only name grants this build knows, so the ones it does
// not (written by a newer release) are carried over instead of being
// dropped by an editor that never showed them.
for _, permission := range user.Permissions {
if !IsValidPermission(permission) && !Has(granted, permission) {
granted = append(granted, permission)
}
}
}
if actor == username && user.Role == RoleAdmin &&
((patch.Role != nil && *patch.Role != RoleAdmin) || (patch.Enabled != nil && !*patch.Enabled)) {
return nil, ErrSelfModification
return nil, nil, ErrSelfModification
}
if user.SystemAccount &&
((patch.Role != nil && *patch.Role != RoleAdmin) || (patch.Enabled != nil && !*patch.Enabled)) {
return nil, ErrSystemAccount
return nil, nil, ErrSystemAccount
}
changed := false
// Only disabling the account invalidates its login. A role change does not:
// the principal is rebuilt from the account file on every request, and the
// caller refreshes the user's long-lived connections so they reconnect under
// the role's new grants. Bumping the token version for a role change would
// log the account out mid-session just because it was promoted or demoted.
loginInvalidated := false
if patch.Role != nil && user.Role != *patch.Role {
user.Role = *patch.Role
changed = true
@@ -265,20 +312,30 @@ func (s *Store) Update(actor, username string, patch UserPatch) (*User, error) {
if patch.Enabled != nil && user.Enabled != *patch.Enabled {
user.Enabled = *patch.Enabled
changed = true
loginInvalidated = true
}
if patch.Permissions != nil && !permissionsEqual(user.Permissions, granted) {
user.Permissions = granted
changed = true
}
if !changed {
return &user, nil
return previous, cloneUser(&user), nil
}
db.Users[index] = user
if enabledAdminCount(db.Users) == 0 {
return nil, ErrLastAdmin
return nil, nil, ErrLastAdmin
}
// Permission and role updates keep the login valid: the principal is rebuilt
// from the store on every request, so new grants apply immediately and
// revoked ones are enforced by dropping connections from the handler.
if loginInvalidated {
db.Users[index].TokenVersion++
}
db.Users[index].TokenVersion++
if err = s.saveLocked(db); err != nil {
return nil, err
return nil, nil, err
}
return cloneUser(&db.Users[index]), nil
return previous, cloneUser(&db.Users[index]), nil
}
func (s *Store) Delete(actor, username string) error {
@@ -378,6 +435,7 @@ func (s *Store) loadLocked(migrate bool) (*database, error) {
if defaultErr != nil {
return nil, defaultErr
}
materializePermissions(db)
if migrate {
if saveErr := s.saveLocked(db); saveErr != nil {
return nil, saveErr
@@ -391,6 +449,7 @@ func (s *Store) loadLocked(migrate bool) (*database, error) {
var db database
if err = json.Unmarshal(data, &db); err == nil && db.Version != 0 {
materializePermissions(&db)
if err = validateDatabase(&db); err != nil {
return nil, err
}
@@ -420,6 +479,7 @@ func (s *Store) loadLocked(migrate bool) (*database, error) {
SystemAccount: true,
}},
}
materializePermissions(&db)
if migrate {
if err = s.saveLocked(&db); err != nil {
return nil, err
@@ -528,6 +588,9 @@ func validateDatabase(db *database) error {
if user.PasswordHash == "" || !IsValidRole(user.Role) {
return errors.New("account file contains an invalid user")
}
if err := ValidateStoredPermissions(user.Permissions); err != nil {
return fmt.Errorf("account file contains invalid permissions for %q: %w", user.Username, err)
}
if _, exists := seen[user.Username]; exists {
return errors.New("account file contains duplicate usernames")
}
@@ -565,6 +628,46 @@ func userIndex(users []User, username string) int {
return -1
}
// materializePermissions replaces the grant shapes of earlier releases with an
// explicit set: an account that predates fine-grained permissions keeps exactly
// what the user role used to grant, and the split media grants are translated.
// After this the store never holds a nil set, so "no grants" and "never
// configured" can no longer be confused, and no caller has to special-case nil.
func materializePermissions(db *database) {
for index := range db.Users {
if db.Users[index].Permissions == nil {
db.Users[index].Permissions = legacyUserPermissions()
continue
}
db.Users[index].Permissions = NormalizeStoredPermissions(db.Users[index].Permissions)
}
}
func clonePermissions(permissions []Permission) []Permission {
return append([]Permission{}, permissions...)
}
func permissionsEqual(a, b []Permission) bool {
if (a == nil) != (b == nil) {
return false
}
if len(a) != len(b) {
return false
}
// The stored order carries no meaning, so a reordering must not look like
// a change worth writing back.
seen := make(map[Permission]struct{}, len(a))
for _, permission := range a {
seen[permission] = struct{}{}
}
for _, permission := range b {
if _, exists := seen[permission]; !exists {
return false
}
}
return true
}
func enabledAdminCount(users []User) int {
count := 0
for _, user := range users {

View File

@@ -96,14 +96,14 @@ func TestUserLifecycleAndTokenRevocation(t *testing.T) {
}
disabled := false
if _, err = store.Update("admin", "alice", UserPatch{Enabled: &disabled}); err != nil {
if _, _, err = store.Update("admin", "alice", UserPatch{Enabled: &disabled}); err != nil {
t.Fatal(err)
}
if _, ok, err := store.Authenticate("alice", "new-password"); err != nil || ok {
t.Fatalf("disabled login: ok=%v err=%v", ok, err)
}
enabled := true
if _, err = store.Update("admin", "alice", UserPatch{Enabled: &enabled}); err != nil {
if _, _, err = store.Update("admin", "alice", UserPatch{Enabled: &enabled}); err != nil {
t.Fatal(err)
}
if err = store.Delete("admin", "alice"); err != nil {
@@ -120,7 +120,7 @@ func TestLastAdminAndSelfProtection(t *testing.T) {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
userRole := RoleUser
if _, err := store.Update("admin", "admin", UserPatch{Role: &userRole}); !errors.Is(err, ErrSelfModification) {
if _, _, err := store.Update("admin", "admin", UserPatch{Role: &userRole}); !errors.Is(err, ErrSelfModification) {
t.Fatalf("self demotion error = %v", err)
}
if err := store.Delete("admin", "admin"); !errors.Is(err, ErrSelfDelete) {
@@ -130,10 +130,10 @@ func TestLastAdminAndSelfProtection(t *testing.T) {
t.Fatal(err)
}
disabled := false
if _, err := store.Update("second", "admin", UserPatch{Enabled: &disabled}); !errors.Is(err, ErrSystemAccount) {
if _, _, err := store.Update("second", "admin", UserPatch{Enabled: &disabled}); !errors.Is(err, ErrSystemAccount) {
t.Fatalf("disable device owner error = %v", err)
}
if _, err := store.Update("second", "admin", UserPatch{Role: &userRole}); !errors.Is(err, ErrSystemAccount) {
if _, _, err := store.Update("second", "admin", UserPatch{Role: &userRole}); !errors.Is(err, ErrSystemAccount) {
t.Fatalf("demote device owner error = %v", err)
}
if err := store.Delete("second", "admin"); !errors.Is(err, ErrSystemAccount) {

View File

@@ -35,64 +35,49 @@ func GetKvmVision() *KvmVision {
return kvmVision
}
func (k *KvmVision) ReadMjpeg(width uint16, height uint16, quality uint16) (data []byte, result int) {
k.mutex.RLock()
defer k.mutex.RUnlock()
if k.closed {
return nil, -1
}
var (
kvmData *C.uint8_t
dataSize C.uint32_t
)
result = int(C.kvmv_read_img(
C.uint16_t(width),
C.uint16_t(height),
C.uint8_t(0),
C.uint16_t(quality),
&kvmData,
&dataSize,
))
if result < 0 {
log.Errorf("failed to read kvm image: %v", result)
return
}
defer C.free_kvmv_data(&kvmData)
data = C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
return
func (k *KvmVision) ReadMjpeg(width uint16, height uint16, quality uint16) ([]byte, int) {
frame := k.AcquireMjpeg(width, height, quality, uint16(GetScreen().FPS))
defer frame.Release()
return frame.Data, frame.Result
}
func (k *KvmVision) ReadH264(width uint16, height uint16, bitRate uint16) (data []byte, result int) {
func (k *KvmVision) AcquireMjpeg(width, height, quality, fps uint16) *VideoFrame {
return k.acquireFrame(width, height, quality, fps, 0)
}
func (k *KvmVision) AcquireH264(width, height, bitRate, fps uint16) *VideoFrame {
return k.acquireFrame(width, height, bitRate, fps, 1)
}
func (k *KvmVision) acquireFrame(width, height, quality, fps uint16, codec uint8) *VideoFrame {
k.mutex.RLock()
defer k.mutex.RUnlock()
if k.closed {
return nil, -1
return NewVideoFrame(nil, -1, 0, nil)
}
var (
kvmData *C.uint8_t
dataSize C.uint32_t
)
result = int(C.kvmv_read_img(
C.uint16_t(width),
C.uint16_t(height),
C.uint8_t(1),
C.uint16_t(bitRate),
&kvmData,
&dataSize,
))
if result < 0 {
log.Errorf("failed to read kvm image: %v", result)
return
var frame C.kvmv_frame_t
result := int(C.kvmv_read_img(C.uint16_t(width), C.uint16_t(height),
C.uint8_t(codec), C.uint16_t(quality), C.uint16_t(fps), &frame))
if frame.data == nil {
return NewVideoFrame(nil, result, int64(frame.timestamp), nil)
}
defer C.free_kvmv_data(&kvmData)
data := C.GoBytes(unsafe.Pointer(frame.data), C.int(frame.size))
return NewVideoFrame(data, result, int64(frame.timestamp), func() {
k.mutex.RLock()
defer k.mutex.RUnlock()
C.kvmv_release_frame(&frame)
})
}
data = C.GoBytes(unsafe.Pointer(kvmData), C.int(dataSize))
return
func (k *KvmVision) StopH264() { k.stopStream(1) }
func (k *KvmVision) StopMjpeg() { k.stopStream(0) }
func (k *KvmVision) stopStream(codec uint8) {
k.mutex.RLock()
defer k.mutex.RUnlock()
if !k.closed {
C.kvmv_stop_stream(C.uint8_t(codec))
}
}
func (k *KvmVision) SetHDMI(enable bool) int {

View File

@@ -1,10 +1,13 @@
package common
import "sync"
import (
"os"
"strconv"
"strings"
"sync"
)
type Screen struct {
Width uint16
Height uint16
FPS int
Quality uint16
BitRate uint16
@@ -16,15 +19,6 @@ var (
screenOnce sync.Once
)
// ResolutionMap height to width
var ResolutionMap = map[uint16]uint16{
1080: 1920,
720: 1280,
600: 800,
480: 640,
0: 0,
}
var QualityMap = map[uint16]bool{
100: true,
80: true,
@@ -42,8 +36,6 @@ var BitRateMap = map[uint16]bool{
func GetScreen() *Screen {
screenOnce.Do(func() {
screen = &Screen{
Width: 0,
Height: 0,
Quality: 80,
FPS: 30,
BitRate: 3000,
@@ -56,13 +48,6 @@ func GetScreen() *Screen {
func SetScreen(key string, value int) {
switch key {
case "resolution":
height := uint16(value)
if width, ok := ResolutionMap[height]; ok {
screen.Width = width
screen.Height = height
}
case "quality":
if value > 100 {
screen.BitRate = uint16(value)
@@ -79,11 +64,6 @@ func SetScreen(key string, value int) {
}
func CheckScreen() {
if _, ok := ResolutionMap[screen.Height]; !ok {
screen.Width = 1920
screen.Height = 1080
}
if _, ok := QualityMap[screen.Quality]; !ok {
screen.Quality = 80
}
@@ -93,6 +73,21 @@ func CheckScreen() {
}
}
func InputSize() (uint16, uint16) {
read := func(path string) uint16 {
data, err := os.ReadFile(path)
if err != nil {
return 0
}
value, err := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 16)
if err != nil {
return 0
}
return uint16(value)
}
return read("/kvmapp/kvm/width"), read("/kvmapp/kvm/height")
}
func validateFPS(fps int) int {
if fps > 60 {
return 60

View File

@@ -0,0 +1,26 @@
package common
import "sync"
type VideoFrame struct {
Data []byte
Result int
Timestamp int64
release func()
once sync.Once
}
func NewVideoFrame(data []byte, result int, timestamp int64, release func()) *VideoFrame {
return &VideoFrame{Data: data, Result: result, Timestamp: timestamp, release: release}
}
func (f *VideoFrame) Release() {
if f == nil {
return
}
f.once.Do(func() {
if f.release != nil {
f.release()
}
})
}

Binary file not shown.

Binary file not shown.

View File

@@ -1,71 +1,43 @@
#ifndef KVM_VISION_H_
#define KVM_VISION_H_
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#include <fcntl.h> /* low-level i/o */
#include <unistd.h>
#include <string.h>
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <pthread.h>
#include <time.h>
#define IMG_BUFFER_FULL -3
#define IMG_VENC_ERROR -2
#define IMG_NOT_EXIST -1
#define IMG_MJPEG_TYPE 0
#define IMG_H264_TYPE_SPS 1
#define IMG_H264_TYPE_PPS 2
#define IMG_H264_TYPE_IF 3
#define IMG_H264_TYPE_PF 4
#define IMG_BUFFER_FULL -3
#define IMG_VENC_ERROR -2
#define IMG_NOT_EXIST -1
#define IMG_MJPEG_TYPE 0
#define IMG_H264_TYPE_IF 3
#define IMG_H264_TYPE_PF 4
#define NORMAL_RES 0
#define NEW_RES 1
#define UNSUPPORT_RES 2
#define UNKNOWN_RES 3
#define ERROR_RES 4
typedef struct {
uint8_t *data;
uint32_t size;
uint64_t timestamp;
uint64_t token;
uint8_t codec;
} kvmv_frame_t;
void kvmv_init(uint8_t _debug_info_en);
void set_venc_auto_recyc(uint8_t _enable);
/**********************************************************************************
* @name kvmv_read_img
* @author Sipeed BuGu
* @date 2024/10/25
* @version R1.0
* @brief Acquire the encoded image with auto init
* @param _width @input: Output image width
* @param _height @input: Output image height
* @param _type @input: Encode type
* @param _qlty @input: MJPEG: (50-100) | H264: (500-10000)
* @param _pp_kvm_data @output: Encode data
* @param _p_kvmv_data_size @output: Encode data size
* @return
-7: HDMI INPUT RES ERROR
-6: Unsupported resolution, please modify it in the host settings.
-5: Retrieving image, please wait
-4: Modifying image resolution, please wait
-3: img buffer full
-2: VENC Error
-1: No images were acquired
0: Acquire MJPEG encoded images
1: Acquire H264 encoded images(SPS)[Deprecated]
2: Acquire H264 encoded images(PPS)[Deprecated]
3: Acquire H264 encoded images(I)
4: Acquire H264 encoded images(P)
5: IMG not changed
**********************************************************************************/
int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _qlty, uint8_t** _pp_kvm_data, uint32_t* _p_kvmv_data_size);
int free_kvmv_data(uint8_t ** _pp_kvm_data);
void free_all_kvmv_data();
void set_h264_gop(uint8_t _gop);
void set_frame_detact(uint8_t _frame_detact);
void kvmv_deinit();
uint8_t kvmv_hdmi_control(uint8_t _en);
void kvmv_init(uint8_t debug_info);
void kvmv_deinit(void);
// type: 0 = MJPEG, 1 = H.264. A zero width/height follows the input size.
// quality: MJPEG 50..100, H.264 500..10000 Kbps. fps: 1..60.
// Returns -7 input error, -6 unsupported size, -5 busy, -4 reconfiguring,
// -3 allocation failure, -2 encoder error, -1 no frame, 0 JPEG, 3 IDR,
// 4 P-frame, or 5 unchanged JPEG. Release every successful frame once.
int kvmv_read_img(uint16_t width, uint16_t height, uint8_t type,
uint16_t quality, uint16_t fps, kvmv_frame_t *frame);
// Frees the copied bytes and releases the encoder lease. Safe after shutdown.
void kvmv_release_frame(kvmv_frame_t *frame);
void kvmv_stop_stream(uint8_t type);
void set_h264_gop(uint8_t gop);
void set_frame_detact(uint8_t interval);
uint8_t kvmv_hdmi_control(uint8_t enable);
uint8_t kvmv_hdmi_signal_active(void);
#ifdef __cplusplus

View File

@@ -13,6 +13,7 @@ import (
"NanoKVM-Server/logger"
"NanoKVM-Server/middleware"
"NanoKVM-Server/router"
"NanoKVM-Server/service/storage/diskfs"
"NanoKVM-Server/service/vm"
"NanoKVM-Server/service/vm/jiggler"
"NanoKVM-Server/utils"
@@ -49,6 +50,11 @@ func initialize() {
// run mouse jiggler
jiggler.GetJiggler().Run()
// A medium left mounted by a crash or a restart keeps its loop device
// busy; nothing can be mounted by this process yet, so whatever is there
// is stale.
diskfs.CleanupStale()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
go func() {
@@ -123,5 +129,6 @@ func run() {
}
func dispose() {
diskfs.CleanupStale()
common.GetKvmVision().Close()
}

View File

@@ -22,8 +22,18 @@ const (
)
type Principal struct {
Username string
Role authn.Role
Username string
Role authn.Role
Permissions []authn.Permission
}
// Has reports whether the principal effectively holds the permission.
// Administrators always do.
func (p Principal) Has(permission authn.Permission) bool {
if p.Role == authn.RoleAdmin {
return true
}
return authn.Has(p.Permissions, permission)
}
type Token struct {
@@ -48,8 +58,34 @@ func CheckToken() gin.HandlerFunc {
}
requestContext, cancel := context.WithCancel(c.Request.Context())
sessionHandle := newSessionHandle()
requestContext = WithSessionHandle(requestContext, sessionHandle)
c.Request = c.Request.WithContext(requestContext)
unregister := activeSessions.register(principal.Username, cancel)
channel := SessionChannelForPath(c.Request.URL.Path)
unregister := activeSessions.register(
principal.Username,
cancel,
sessionHandle,
channel,
)
// The principal was resolved before this session could be found by a
// revocation, so a grant edit landing in between would miss it. Now that
// it is registered, any later edit drops it; re-reading the account picks
// up any earlier one. Only the long-lived channels are dropped by a grant
// edit, so a short request keeps the principal it was authorized with.
if channel != "" {
user, err := authn.DefaultStore.ValidateToken(principal.Username, token.TokenVersion)
if err != nil {
unregister()
cancel()
abortUnauthorized(c)
return
}
principal.Role = user.Role
principal.Permissions = authn.EffectivePermissions(user)
c.Set(principalContextKey, principal)
}
timer := time.AfterFunc(time.Until(token.ExpiresAt.Time), cancel)
go watchSessionState(requestContext, cancel, principal.Username, token.TokenVersion, sessionRecheckDelay)
defer func() {
@@ -90,8 +126,31 @@ func RequireRole(roles ...authn.Role) gin.HandlerFunc {
return
}
if _, ok = allowed[principal.Role]; !ok {
c.JSON(http.StatusForbidden, "forbidden")
c.Abort()
abortForbidden(c)
return
}
c.Next()
}
}
// RequirePermission allows the request when the principal holds at least one
// of the listed permissions. Administrators implicitly hold every permission.
func RequirePermission(permissions ...authn.Permission) gin.HandlerFunc {
return func(c *gin.Context) {
principal, ok := CurrentPrincipal(c)
if !ok {
abortUnauthorized(c)
return
}
allowed := false
for _, permission := range permissions {
if principal.Has(permission) {
allowed = true
break
}
}
if !allowed {
abortForbidden(c)
return
}
c.Next()
@@ -138,7 +197,7 @@ func CheckTokenOrLoopbackInternalToken() gin.HandlerFunc {
func authenticate(c *gin.Context) (Principal, *Token, bool) {
conf := config.GetInstance()
if conf.Authentication == "disable" {
return Principal{Username: "admin", Role: authn.RoleAdmin}, nil, true
return Principal{Username: "admin", Role: authn.RoleAdmin, Permissions: authn.PermissionIDs()}, nil, true
}
cookie, err := c.Cookie(CookieName)
@@ -154,7 +213,11 @@ func authenticate(c *gin.Context) (Principal, *Token, bool) {
log.Debugf("validate session for %q: %s", token.Username, err)
return Principal{}, nil, false
}
return Principal{Username: user.Username, Role: user.Role}, token, true
return Principal{
Username: user.Username,
Role: user.Role,
Permissions: authn.EffectivePermissions(user),
}, token, true
}
func abortUnauthorized(c *gin.Context) {
@@ -162,6 +225,11 @@ func abortUnauthorized(c *gin.Context) {
c.Abort()
}
func abortForbidden(c *gin.Context) {
c.JSON(http.StatusForbidden, "forbidden")
c.Abort()
}
func GenerateJWT(username string, tokenVersion uint64) (string, error) {
conf := config.GetInstance()
now := time.Now()

View File

@@ -114,7 +114,7 @@ func TestParseJWTRejectsExpiredToken(t *testing.T) {
func TestRevokeUserSessionsCancelsActiveRequests(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
unregister := activeSessions.register("alice", cancel)
unregister := activeSessions.register("alice", cancel, newSessionHandle(), "")
defer unregister()
RevokeUserSessions("alice")

View File

@@ -15,21 +15,78 @@ import (
const SessionRevokedCloseCode = 4401
// SessionRefreshedCloseCode closes connections whose permissions changed while
// the underlying login is still valid. Clients are expected to reconnect and
// refresh their account state instead of returning to the login page.
const SessionRefreshedCloseCode = 4403
type sessionHandleKey struct{}
// Long-lived connection channels, used to drop only the connections affected
// by a permission change instead of every session of the user.
const (
SessionChannelConsole = "console" // /api/ws (input events, status pushes)
SessionChannelStream = "stream" // /api/stream/* (video signaling and frames)
SessionChannelTerminal = "terminal" // /api/vm/terminal
SessionChannelAi = "ai" // /api/picoclaw/gateway/ws
)
// SessionChannelForPath classifies long-lived endpoints; everything else
// returns an empty channel and is only affected by hard revocation.
func SessionChannelForPath(path string) string {
switch {
case path == "/api/ws":
return SessionChannelConsole
case strings.HasPrefix(path, "/api/stream/"):
return SessionChannelStream
case path == "/api/vm/terminal":
return SessionChannelTerminal
case path == "/api/picoclaw/gateway/ws":
return SessionChannelAi
default:
return ""
}
}
// sessionHandle lets a revocation pick the WebSocket close code before the
// request context is canceled.
type sessionHandle struct {
closeCode atomic.Int32
}
type sessionEntry struct {
cancel context.CancelFunc
handle *sessionHandle
channel string
}
type sessionRegistry struct {
mutex sync.Mutex
nextID atomic.Uint64
byUserID map[string]map[uint64]context.CancelFunc
byUserID map[string]map[uint64]*sessionEntry
}
var activeSessions = &sessionRegistry{byUserID: make(map[string]map[uint64]context.CancelFunc)}
var activeSessions = &sessionRegistry{byUserID: make(map[string]map[uint64]*sessionEntry)}
func (r *sessionRegistry) register(username string, cancel context.CancelFunc) func() {
func newSessionHandle() *sessionHandle {
handle := &sessionHandle{}
handle.closeCode.Store(SessionRevokedCloseCode)
return handle
}
// WithSessionHandle attaches the handle to the request context so
// WatchWebSocket can honor the close code chosen at revocation time.
func WithSessionHandle(ctx context.Context, handle *sessionHandle) context.Context {
return context.WithValue(ctx, sessionHandleKey{}, handle)
}
func (r *sessionRegistry) register(username string, cancel context.CancelFunc, handle *sessionHandle, channel string) func() {
id := r.nextID.Add(1)
r.mutex.Lock()
if r.byUserID[username] == nil {
r.byUserID[username] = make(map[uint64]context.CancelFunc)
r.byUserID[username] = make(map[uint64]*sessionEntry)
}
r.byUserID[username][id] = cancel
r.byUserID[username][id] = &sessionEntry{cancel: cancel, handle: handle, channel: channel}
r.mutex.Unlock()
return func() {
@@ -42,26 +99,79 @@ func (r *sessionRegistry) register(username string, cancel context.CancelFunc) f
}
}
// RevokeUserSessions hard-drops every active session of the user. The login
// is expected to be invalid afterwards; connections close as unauthorized.
func RevokeUserSessions(username string) {
dropUserSessions(username, SessionRevokedCloseCode, nil)
}
// RefreshUserSessions drops the user's long-lived connections on the given
// channels while their login stays valid, so those clients reconnect under
// the updated permissions. Unrelated connections (e.g. the video stream when
// only console control was revoked) stay untouched.
func RefreshUserSessions(username string, channels ...string) {
dropUserSessions(username, SessionRefreshedCloseCode, channels)
}
func dropUserSessions(username string, closeCode int, channels []string) {
activeSessions.mutex.Lock()
sessions := activeSessions.byUserID[username]
delete(activeSessions.byUserID, username)
if channels == nil {
delete(activeSessions.byUserID, username)
}
dropped := make([]*sessionEntry, 0, len(sessions))
for id, entry := range sessions {
if !channelMatches(entry.channel, channels) {
continue
}
dropped = append(dropped, entry)
if channels != nil {
delete(sessions, id)
}
}
if channels != nil && len(sessions) == 0 {
delete(activeSessions.byUserID, username)
}
activeSessions.mutex.Unlock()
for _, cancel := range sessions {
cancel()
for _, entry := range dropped {
entry.handle.closeCode.Store(int32(closeCode))
entry.cancel()
}
}
// channelMatches reports whether the entry is affected. A nil channel list
// means a hard revocation, which drops everything.
func channelMatches(channel string, channels []string) bool {
if channels == nil {
return true
}
for _, candidate := range channels {
if channel == candidate {
return true
}
}
return false
}
func WatchWebSocket(ctx context.Context, connection *websocket.Conn) func() {
stopped := make(chan struct{})
var stopOnce sync.Once
go func() {
select {
case <-ctx.Done():
closeCode := SessionRevokedCloseCode
reason := "session expired or revoked"
if handle, ok := ctx.Value(sessionHandleKey{}).(*sessionHandle); ok {
if code := int(handle.closeCode.Load()); code == SessionRefreshedCloseCode {
closeCode = code
reason = "permissions changed"
}
}
_ = connection.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(SessionRevokedCloseCode, "session expired or revoked"),
websocket.FormatCloseMessage(closeCode, reason),
time.Now().Add(2*time.Second),
)
_ = connection.Close()

View File

@@ -6,8 +6,9 @@ type LoginReq struct {
}
type GetAccountRsp struct {
Username string `json:"username"`
Role string `json:"role"`
Username string `json:"username"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
}
type ChangePasswordReq struct {
@@ -20,10 +21,25 @@ type IsPasswordUpdatedRsp struct {
}
type UserInfo struct {
Username string `json:"username"`
Role string `json:"role"`
Enabled bool `json:"enabled"`
SystemAccount bool `json:"systemAccount,omitempty"`
Username string `json:"username"`
Role string `json:"role"`
Enabled bool `json:"enabled"`
Permissions []string `json:"permissions"`
SystemAccount bool `json:"systemAccount,omitempty"`
}
type ListPermissionsRsp struct {
Permissions []PermissionInfo `json:"permissions"`
}
// PermissionInfo describes one grantable permission: the group it belongs to,
// how far it reaches, and the grants it implies. The client renders the editor
// from this catalog instead of keeping its own copy of the model.
type PermissionInfo struct {
ID string `json:"id"`
Group string `json:"group"`
Risk string `json:"risk"`
Implies []string `json:"implies,omitempty"`
}
type ListUsersRsp struct {
@@ -31,12 +47,23 @@ type ListUsersRsp struct {
}
type CreateUserReq struct {
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
Role string `json:"role" validate:"required"`
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
Role string `json:"role" validate:"required"`
Permissions []string `json:"permissions"`
}
type UpdateUserReq struct {
Role *string `json:"role"`
Enabled *bool `json:"enabled"`
Role *string `json:"role"`
Enabled *bool `json:"enabled"`
Permissions *[]string `json:"permissions"`
// RotateMcpKey asks to regenerate the device-wide MCP API key when this
// change takes the mcp grant away. The administrator decides: rotating
// disconnects every MCP client, keeping it leaves a copied key working.
RotateMcpKey bool `json:"rotateMcpKey"`
}
// UpdateUserRsp says whether the change regenerated the MCP API key.
type UpdateUserRsp struct {
McpKeyRotated bool `json:"mcpKeyRotated"`
}

View File

@@ -13,4 +13,6 @@ type StatusImageRsp struct {
Status string `json:"status"`
File string `json:"file"`
Percentage string `json:"percentage"`
Loaded int64 `json:"loaded"` // bytes transferred so far
Total int64 `json:"total"` // expected bytes, 0 when unknown
}

100
server/proto/media.go Normal file
View File

@@ -0,0 +1,100 @@
package proto
import "time"
// Virtual media wire types. A medium is named by a type plus a path relative
// to the media directory: the server resolves the id itself, so a request
// never carries an absolute path it could be talked into deleting.
const (
MediaTypeImage = "image"
MediaTypePartition = "partition"
)
type MediaItem struct {
Type string `json:"type"`
Rel string `json:"rel"`
Name string `json:"name"`
Size uint64 `json:"size"`
FS string `json:"fs"`
Browsable bool `json:"browsable"`
Attached bool `json:"attached"`
Modified time.Time `json:"modifiedAt"`
}
type MediaCaps struct {
Loop bool `json:"loop"`
}
type GetMediaRsp struct {
Media []MediaItem `json:"media"`
Caps MediaCaps `json:"capabilities"`
}
type MediaStateRsp struct {
State string `json:"state"` // none | image | partition | unsupported
Type string `json:"type"` // type of the attached medium
Rel string `json:"rel"` // rel of the attached medium, when it is an image
HidOnly bool `json:"hidOnly"` // the gadget has no mass storage at all
Open []string `json:"open"` // media the file manager currently holds open
Cdrom bool `json:"cdrom"` // the attached medium is served as a CD-ROM
// LocalWritable is false while /data is mounted read-only, which is what
// the target machine owning the partition does. Every write would fail
// with EROFS, so the UI disables them instead of asking.
LocalWritable bool `json:"localWritable"`
}
type MediaRefReq struct {
Type string `json:"type" validate:"omitempty,oneof=image partition"`
Rel string `json:"rel"`
}
type MountMediaReq struct {
Type string `json:"type" validate:"omitempty,oneof=image partition"`
Rel string `json:"rel"`
Cdrom bool `json:"cdrom"`
// Force re-enumerates the gadget to take the medium back from a target
// machine that is holding it. It costs the target machine its unflushed
// writes, so the UI asks before setting it.
Force bool `json:"force"`
}
type EjectMediaReq struct {
Force bool `json:"force"`
}
type MediaStatusRsp struct {
Status string `json:"status"`
Name string `json:"name"`
Percentage string `json:"percentage"`
Loaded int64 `json:"loaded"` // bytes transferred so far
Total int64 `json:"total"` // expected bytes, 0 when unknown
Message string `json:"message"`
}
type MediaFile struct {
Name string `json:"name"`
Size int64 `json:"size"`
IsDir bool `json:"isDir"`
Modified time.Time `json:"modifiedAt"`
}
type GetMediaFilesRsp struct {
Path string `json:"path"`
Entries []MediaFile `json:"entries"`
Free uint64 `json:"free"`
Total uint64 `json:"total"`
// Writing is true while the medium is seeing block level writes: a zero
// length entry in that state is a transfer in progress, not an empty file.
Writing bool `json:"writing"`
}
type MediaPathReq struct {
Type string `json:"type" validate:"omitempty,oneof=image partition"`
Rel string `json:"rel"`
Path string `json:"path" validate:"required"`
// ExistsOK turns "the directory is already there" into success. An upload
// of a whole tree recreates folders that may already exist on the medium,
// and stopping at the first one would leave the tree half copied.
ExistsOK bool `json:"existsOk"`
}

View File

@@ -30,8 +30,8 @@ type GetGpioRsp struct {
}
type SetScreenReq struct {
Type string `validate:"required"` // resolution / fps / quality
Value int `validate:"number"` // value
Type string `validate:"required,oneof=type fps quality gop"`
Value int `validate:"number"` // value
}
type GetScriptsRsp struct {
@@ -72,6 +72,10 @@ type GetVirtualDeviceRsp struct {
type UpdateVirtualDeviceReq struct {
Device string `validate:"required"`
// Enable turns the device on or off instead of toggling it. A caller that
// says "enable" must not switch a running device off because it happened to
// be already on.
Enable *bool `json:"enable"`
}
type UpdateVirtualDeviceRsp struct {

View File

@@ -10,7 +10,7 @@ import (
func applicationRouter(r *gin.Engine) {
service := application.NewService()
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermUpdate))
api.GET("/application/version", service.GetVersion) // get application version
api.POST("/application/update", service.Update) // update application

View File

@@ -24,6 +24,7 @@ func authRouter(r *gin.Engine) {
middleware.CheckToken(),
middleware.RequireRole(authn.RoleAdmin),
)
admin.GET("/auth/permissions", service.ListPermissions)
admin.GET("/auth/users", service.ListUsers)
admin.POST("/auth/users", service.CreateUser)
admin.PUT("/auth/users/:username", service.UpdateUser)

View File

@@ -22,7 +22,7 @@ type setAIControlModeRequest struct {
func controlRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *picoclaw.Service) {
group := r.Group("/api/ai/control").Use(
middleware.CheckToken(),
middleware.RequireRole(authn.RoleAdmin),
middleware.RequirePermission(authn.PermAi),
)
group.GET("/status", func(c *gin.Context) {
status, err := control.Status()

View File

@@ -2,19 +2,25 @@ package router
import (
"NanoKVM-Server/authn"
"NanoKVM-Server/service/download"
"github.com/gin-gonic/gin"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/download"
"github.com/gin-gonic/gin"
)
func downloadRouter(r *gin.Engine) {
service := download.NewService()
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
// A signed URL that ends up on the data partition changes what this device
// keeps, so it follows the library write grant.
write := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermLibraryWrite))
// Progress and availability are reads: a user who may attach a medium can
// take /data away from the downloader, so they need to see whether one is
// running, and reading the library is implied by every media grant.
read := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermLibraryRead))
api.POST("/download/image", service.DownloadImage) // download image
api.POST("/download/image/cancel", service.CancelDownloadImage) // cancel image download
api.GET("/download/image/status", service.StatusImage) // download image
api.GET("/download/image/enabled", service.ImageEnabled) // download image
api.POST("/download/file", service.DownloadImageFile) // download image
write.POST("/download/image", service.DownloadImage) // download image
write.POST("/download/image/cancel", service.CancelDownloadImage) // cancel image download
read.GET("/download/image/status", service.StatusImage) // download image
read.GET("/download/image/enabled", service.ImageEnabled) // download image
write.POST("/download/file", service.DownloadImageFile) // upload image file
}

View File

@@ -11,7 +11,7 @@ import (
func extensionsRouter(r *gin.Engine) {
api := r.Group("/api/extensions").Use(
middleware.CheckToken(),
middleware.RequireRole(authn.RoleAdmin),
middleware.RequirePermission(authn.PermTailscale),
)
ts := tailscale.NewService()

View File

@@ -16,26 +16,24 @@ func HIDLoopbackHTTPAllowedPaths() []string {
func hidRouter(r *gin.Engine) {
service := hid.NewService()
api := r.Group("/api").Use(middleware.CheckToken())
view := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermConsoleView))
control := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermConsoleControl))
system := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermDevice))
localAPI := r.Group("/api/internal").Use(middleware.CheckLoopbackInternalToken())
api.POST("/hid/paste", service.Paste) // paste
control.POST("/hid/paste", service.Paste) // paste
api.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
api.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // get shortcut leader key
view.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
view.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // get shortcut leader key
api.GET("/hid/mode", service.GetHidMode) // get hid mode
api.GET("/hid/leds", service.GetKeyboardLedStatus)
view.GET("/hid/mode", service.GetHidMode) // get hid mode
view.GET("/hid/leds", service.GetKeyboardLedStatus)
admin := r.Group("/api").Use(
middleware.CheckToken(),
middleware.RequireRole(authn.RoleAdmin),
)
admin.POST("/hid/mode", service.SetHidMode) // set hid mode
admin.POST("/hid/reset", service.ResetHid) // reset hid
admin.POST("/hid/shortcut", service.AddShortcut)
admin.DELETE("/hid/shortcut", service.DeleteShortcut)
admin.POST("/hid/shortcut/leader-key", service.SetLeaderKey)
system.POST("/hid/mode", service.SetHidMode) // set hid mode
system.POST("/hid/reset", service.ResetHid) // reset hid
system.POST("/hid/shortcut", service.AddShortcut)
system.DELETE("/hid/shortcut", service.DeleteShortcut)
system.POST("/hid/shortcut/leader-key", service.SetLeaderKey)
localAPI.POST("/usb/recover", service.RecoverUSB)
}

View File

@@ -5,6 +5,7 @@ import (
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/auth"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/hid"
mcpservice "NanoKVM-Server/service/mcp"
@@ -24,9 +25,11 @@ func mcpRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *pic
picoclawService.PublishControlModeChangedFrom(status, "mcp_config")
},
)
auth.APIKeyRotator = mcpservice.RotateAPIKey
management := r.Group("/api/mcp").Use(
middleware.CheckToken(),
middleware.RequireRole(authn.RoleAdmin),
middleware.RequirePermission(authn.PermMcp),
)
management.GET("/config", service.GetConfig)
management.POST("/config", service.SetConfig)

View File

@@ -14,21 +14,18 @@ func networkRouter(r *gin.Engine) {
r.POST("/api/network/wifi", service.ConnectWifiNoAuth) // connect Wi-Fi without auth (only available in ap mode)
r.POST("/api/network/wifi/verify", service.VerifyApLogin) // verify ap login
api := r.Group("/api").Use(middleware.CheckToken())
power := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermPower))
network := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermNetwork))
api.POST("/network/wol", service.WakeOnLAN) // wake on lan
api.GET("/network/wol/mac", service.GetMac) // get mac list
power.POST("/network/wol", service.WakeOnLAN) // wake on lan
power.GET("/network/wol/mac", service.GetMac) // get mac list
admin := r.Group("/api").Use(
middleware.CheckToken(),
middleware.RequireRole(authn.RoleAdmin),
)
admin.DELETE("/network/wol/mac", service.DeleteMac) // delete mac
admin.POST("/network/wol/mac/name", service.SetMacName) // set mac name
power.DELETE("/network/wol/mac", service.DeleteMac) // delete mac
power.POST("/network/wol/mac/name", service.SetMacName) // set mac name
admin.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
admin.POST("/network/wifi/connect", service.ConnectWifi) // connect Wi-Fi
admin.POST("/network/wifi/disconnect", service.DisconnectWifi) // disconnect Wi-Fi
admin.GET("/network/dns", service.GetDNS) // get DNS configuration
admin.POST("/network/dns", service.SetDNS) // set DNS configuration
network.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
network.POST("/network/wifi/connect", service.ConnectWifi) // connect Wi-Fi
network.POST("/network/wifi/disconnect", service.DisconnectWifi) // disconnect Wi-Fi
network.GET("/network/dns", service.GetDNS) // get DNS configuration
network.POST("/network/dns", service.SetDNS) // set DNS configuration
}

View File

@@ -42,7 +42,7 @@ func PicoclawLoopbackHTTPAllowedPaths() []string {
func picoclawRouter(r *gin.Engine, service *picoclaw.Service) {
frontendAPI := r.Group(picoclawBasePath).Use(
middleware.CheckToken(),
middleware.RequireRole(authn.RoleAdmin),
middleware.RequirePermission(authn.PermAi),
)
localAPI := r.Group(picoclawBasePath).Use(middleware.CheckLoopbackInternalToken())

View File

@@ -5,8 +5,11 @@ import (
"os"
"path/filepath"
"NanoKVM-Server/service/auth"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/picoclaw"
"NanoKVM-Server/service/ws"
"NanoKVM-Server/webcache"
"github.com/gin-gonic/contrib/static"
"github.com/gin-gonic/gin"
@@ -28,6 +31,7 @@ func web(r *gin.Engine) {
execDir := filepath.Dir(execPath)
webPath := fmt.Sprintf("%s/web", execDir)
r.Use(webcache.Headers(webPath))
r.Use(static.Serve("/", static.LocalFile(webPath, true)))
}
@@ -35,6 +39,8 @@ func server(r *gin.Engine) {
control := controlmode.GetManager()
picoclawService := picoclaw.NewService(control)
auth.SessionNotifier = ws.GetManager().NotifyUser
authRouter(r)
applicationRouter(r)
vmRouter(r)

View File

@@ -1,20 +1,50 @@
package router
import (
"github.com/gin-gonic/gin"
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/storage"
"github.com/gin-gonic/gin"
)
func storageRouter(r *gin.Engine) {
service := storage.NewService()
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
// Reading the library is the one grant the other media grants imply, so it
// alone gates every read: it is what makes the storage page reachable.
read := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermLibraryRead))
// Attaching a medium is what hands it to the target machine; changing the
// library is what changes what this device keeps.
attach := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermMediumAttach))
write := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermLibraryWrite))
// The file handlers serve an image and the shared partition through the same
// path: the first is library content, the second is the surface the target
// machine reads. Which grant applies follows the medium in the request, so
// the route admits either and the handler narrows the pair.
files := r.Group("/api").Use(
middleware.CheckToken(),
middleware.RequirePermission(authn.PermLibraryWrite, authn.PermMediumAttach),
)
api.GET("/storage/image", service.GetImages) // get image list
api.GET("/storage/image/mounted", service.GetMountedImage) // get mounted image
api.POST("/storage/image/mount", service.MountImage) // mount image
api.GET("/storage/cdrom", service.GetCdRom) // get CD-ROM flag
api.POST("/storage/image/delete", service.DeleteImage) // delete image
// Virtual media: the disk images the emulated USB disk exposes, the files
// inside them, and the shared partition the target machine sees.
read.GET("/storage/media", service.GetMedia) // list media
read.GET("/storage/media/state", service.GetMediaState) // what the target machine sees
read.GET("/storage/media/status", service.GetMediaStatus) // transfer progress
read.GET("/storage/media/download", service.DownloadMedia) // download a whole image
read.GET("/storage/media/files", service.ListMediaFiles) // list files inside a medium
read.GET("/storage/media/files/download", service.DownloadMediaFile)
attach.POST("/storage/media/mount", service.MountMedia) // attach a medium to the target machine
attach.POST("/storage/media/eject", service.EjectMedia) // release the medium
write.POST("/storage/media/delete", service.DeleteMedia) // delete a medium
files.POST("/storage/media/files/upload", service.UploadMediaFile)
files.POST("/storage/media/files/mkdir", service.MkdirMediaFile)
files.POST("/storage/media/files/delete", service.DeleteMediaFile)
// The pre-2.6 image API, kept for scripts written against it.
read.GET("/storage/image", service.GetImages) // get image list
read.GET("/storage/image/mounted", service.GetMountedImage) // get mounted image
read.GET("/storage/cdrom", service.GetCdRom) // get CD-ROM flag
attach.POST("/storage/image/mount", service.MountImage) // mount image
write.POST("/storage/image/delete", service.DeleteImage) // delete image
}

View File

@@ -1,6 +1,7 @@
package router
import (
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/stream/direct"
"NanoKVM-Server/service/stream/mjpeg"
@@ -10,7 +11,7 @@ import (
)
func streamRouter(r *gin.Engine) {
api := r.Group("/api").Use(middleware.CheckToken())
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermConsoleView))
api.GET("/stream/mjpeg", mjpeg.Connect) // mjpeg stream
api.POST("/stream/mjpeg/detect", mjpeg.UpdateFrameDetect) // update frame detect

View File

@@ -11,72 +11,95 @@ import (
func vmRouter(r *gin.Engine) {
service := vm.NewService()
api := r.Group("/api").Use(middleware.CheckToken())
admin := r.Group("/api").Use(
middleware.CheckToken(),
middleware.RequireRole(authn.RoleAdmin),
)
use := func(permission authn.Permission) gin.IRoutes {
return r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(permission))
}
// The app shell reads the site title on every page, including the pages an
// account without console access can open, so it asks for no feature grant.
base := r.Group("/api").Use(middleware.CheckToken())
view := use(authn.PermConsoleView)
control := use(authn.PermConsoleControl)
power := use(authn.PermPower)
terminal := use(authn.PermTerminal)
device := use(authn.PermDevice)
network := use(authn.PermNetwork)
api.GET("/vm/info", service.GetInfo) // get device information
api.GET("/vm/hardware", service.GetHardware) // get hardware version
view.GET("/vm/info", service.GetInfo) // get device information
view.GET("/vm/hardware", service.GetHardware) // get hardware version
api.POST("/vm/gpio", service.SetGpio) // update gpio
api.GET("/vm/gpio", service.GetGpio) // get gpio
api.POST("/vm/screen", service.SetScreen) // update screen
power.POST("/vm/gpio", service.SetGpio) // update gpio
power.GET("/vm/gpio", service.GetGpio) // get gpio
api.GET("/vm/input-region", service.GetInputRegion)
api.POST("/vm/input-region", service.SetInputRegion)
api.GET("/vm/input-resolution", service.GetInputResolution)
// Video encoding and capture recovery are view operations: a user who may
// only watch still has to be able to pick a quality their link can carry,
// and every account holds console.view. Mapping pointer motion onto the
// target screen stays console.control.
view.POST("/vm/screen", service.SetScreen) // update screen
admin.GET("/vm/terminal", service.Terminal) // web terminal
view.GET("/vm/input-region", service.GetInputRegion)
control.POST("/vm/input-region", service.SetInputRegion)
view.GET("/vm/input-resolution", service.GetInputResolution)
admin.GET("/vm/script", service.GetScripts) // get script
admin.POST("/vm/script/upload", service.UploadScript) // upload script
admin.POST("/vm/script/run", service.RunScript) // run script
admin.DELETE("/vm/script", service.DeleteScript) // delete script
terminal.GET("/vm/terminal", service.Terminal) // web terminal
admin.GET("/vm/device/virtual", service.GetVirtualDevice) // get virtual device
admin.POST("/vm/device/virtual", service.UpdateVirtualDevice) // update virtual device
terminal.GET("/vm/script", service.GetScripts) // get script
terminal.POST("/vm/script/upload", service.UploadScript) // upload script
terminal.POST("/vm/script/run", service.RunScript) // run script
terminal.DELETE("/vm/script", service.DeleteScript) // delete script
admin.GET("/vm/memory/limit", service.GetMemoryLimit) // get memory limit
admin.POST("/vm/memory/limit", service.SetMemoryLimit) // set memory limit
// The emulated USB devices are device settings, not media or network ones:
// the virtual disk hands the raw data partition to the target machine and the
// RNDIS adapter hands it a network interface, so neither belongs to the
// media library the disk serves or to the network settings of this side.
device.GET("/vm/device/virtual", service.GetVirtualDevice) // get virtual device
device.POST("/vm/device/virtual", service.UpdateVirtualDevice) // update virtual device
admin.GET("/vm/oled", service.GetOLED) // get OLED configuration
admin.POST("/vm/oled", service.SetOLED) // set OLED configuration
device.GET("/vm/memory/limit", service.GetMemoryLimit) // get memory limit
device.POST("/vm/memory/limit", service.SetMemoryLimit) // set memory limit
device.GET("/vm/oled", service.GetOLED) // get OLED configuration
device.POST("/vm/oled", service.SetOLED) // set OLED configuration
// Only supported by PCIe version
api.GET("/vm/hdmi", service.GetHdmiState) // get HDMI state
api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi
admin.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi
admin.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi
admin.POST("/vm/hdmi/timeout", service.SetHdmiIdleTimeout)
//
// Toggling the HDMI capture engine is a device setting, but resetting it is
// capture recovery: a watcher whose screen went black needs it to see
// anything at all, the same way it needs to pick a video mode and a quality.
// Enabling, disabling and the idle timeout stay with the device grant, since
// they change how the device behaves for everyone rather than recover a
// broken picture.
view.GET("/vm/hdmi", service.GetHdmiState) // get HDMI state
view.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi
device.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi
device.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi
device.POST("/vm/hdmi/timeout", service.SetHdmiIdleTimeout)
admin.GET("/vm/ssh", service.GetSSHState) // get SSH state
admin.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH
admin.POST("/vm/ssh/disable", service.DisableSSH) // disable SSH
device.GET("/vm/ssh", service.GetSSHState) // get SSH state
device.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH
device.POST("/vm/ssh/disable", service.DisableSSH) // disable SSH
admin.GET("/vm/swap", service.GetSwap) // get swap file size
admin.POST("/vm/swap", service.SetSwap) // set swap file size
device.GET("/vm/swap", service.GetSwap) // get swap file size
device.POST("/vm/swap", service.SetSwap) // set swap file size
admin.GET("/vm/mouse-jiggler", service.GetMouseJiggler) // get mouse jiggler
admin.POST("/vm/mouse-jiggler/", service.SetMouseJiggler) // set mouse jiggler
device.GET("/vm/mouse-jiggler", service.GetMouseJiggler) // get mouse jiggler
device.POST("/vm/mouse-jiggler/", service.SetMouseJiggler) // set mouse jiggler
api.GET("/vm/hostname", service.GetHostname) // Get Hostname
admin.POST("/vm/hostname", service.SetHostname) // Set Hostname
view.GET("/vm/hostname", service.GetHostname) // Get Hostname
device.POST("/vm/hostname", service.SetHostname) // Set Hostname
api.GET("/vm/web-title", service.GetWebTitle) // Get web title
admin.POST("/vm/web-title", service.SetWebTitle) // Set web title
base.GET("/vm/web-title", service.GetWebTitle) // Get web title
device.POST("/vm/web-title", service.SetWebTitle) // Set web title
admin.GET("/vm/mdns", service.GetMdnsState) // get mDNS state
admin.POST("/vm/mdns/enable", service.EnableMdns) // enable mDNS
admin.POST("/vm/mdns/disable", service.DisableMdns) // disable mDNS
device.GET("/vm/mdns", service.GetMdnsState) // get mDNS state
device.POST("/vm/mdns/enable", service.EnableMdns) // enable mDNS
device.POST("/vm/mdns/disable", service.DisableMdns) // disable mDNS
admin.POST("/vm/tls", service.SetTls) // enable/disable TLS
network.POST("/vm/tls", service.SetTls) // enable/disable TLS
admin.GET("/vm/autostart", service.GetAutostart) // get autostart list
admin.GET("/vm/autostart/:name", service.GetAutostartContent) // get autostart content
admin.DELETE("/vm/autostart/:name", service.DeleteAutostart) // delete autostart script
admin.POST("/vm/autostart/:name", service.UploadAutostart) // upload autostart script
terminal.GET("/vm/autostart", service.GetAutostart) // get autostart list
terminal.GET("/vm/autostart/:name", service.GetAutostartContent) // get autostart content
terminal.DELETE("/vm/autostart/:name", service.DeleteAutostart) // delete autostart script
terminal.POST("/vm/autostart/:name", service.UploadAutostart) // upload autostart script
admin.POST("/vm/system/reboot", service.Reboot) // reboot system
device.POST("/vm/system/reboot", service.Reboot) // reboot system
}

View File

@@ -3,13 +3,14 @@ package router
import (
"github.com/gin-gonic/gin"
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/ws"
)
func wsRouter(r *gin.Engine) {
service := ws.NewService()
api := r.Group("/api").Use(middleware.CheckToken())
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermConsoleView))
api.GET("/ws", service.Connect)
}

View File

@@ -98,8 +98,9 @@ func (s *Service) GetAccount(c *gin.Context) {
return
}
rsp.OkRspWithData(c, &proto.GetAccountRsp{
Username: principal.Username,
Role: string(principal.Role),
Username: principal.Username,
Role: string(principal.Role),
Permissions: permissionStrings(principal.Permissions),
})
}

View File

@@ -14,6 +14,42 @@ import (
var errInvalidPassword = errors.New("invalid password")
// SessionNotifier pushes an account event to a user's live connections. It is
// wired to the websocket manager during router setup; kept as a hook so this
// package does not depend on the cgo-based HID stack.
var SessionNotifier func(username, event, data string)
// APIKeyRotator replaces the device-wide MCP API key. It is wired by the MCP
// router for the same reason as SessionNotifier.
var APIKeyRotator func() error
// rotateAPIKeyIfLost regenerates the MCP API key when the account no longer
// reaches it and the administrator asked for it. The key outlives the web
// session, so a key the account copied keeps working until it is replaced;
// replacing it disconnects every MCP client, so the account editor warns and
// leaves the choice to the administrator. A failed rotation is reported so the
// administrator learns that the old key still works.
func rotateAPIKeyIfLost(previous, current []authn.Permission, requested bool) (bool, error) {
if !authn.Has(previous, authn.PermMcp) || authn.Has(current, authn.PermMcp) {
return false, nil
}
if !requested {
log.Warnf("mcp grant revoked; the MCP API key was kept at the administrator's request")
return false, nil
}
if APIKeyRotator == nil {
return false, nil
}
if err := APIKeyRotator(); err != nil {
log.Errorf("rotate MCP API key after revoking the mcp grant: %s", err)
return false, errAPIKeyNotRotated
}
log.Infof("MCP API key rotated after the mcp grant was revoked")
return true, nil
}
var errAPIKeyNotRotated = errors.New("the change was saved, but the MCP API key could not be regenerated; regenerate it from the MCP settings")
func (s *Service) ListUsers(c *gin.Context) {
var rsp proto.Response
users, err := authn.DefaultStore.List()
@@ -27,12 +63,28 @@ func (s *Service) ListUsers(c *gin.Context) {
Username: user.Username,
Role: string(user.Role),
Enabled: user.Enabled,
Permissions: permissionStrings(user.Permissions),
SystemAccount: user.SystemAccount,
})
}
rsp.OkRspWithData(c, &proto.ListUsersRsp{Users: result})
}
func (s *Service) ListPermissions(c *gin.Context) {
var rsp proto.Response
catalog := authn.Catalog()
permissions := make([]proto.PermissionInfo, 0, len(catalog))
for _, descriptor := range catalog {
permissions = append(permissions, proto.PermissionInfo{
ID: string(descriptor.Permission),
Group: string(descriptor.Group),
Risk: string(descriptor.Risk),
Implies: permissionStrings(descriptor.Implies),
})
}
rsp.OkRspWithData(c, &proto.ListPermissionsRsp{Permissions: permissions})
}
func (s *Service) CreateUser(c *gin.Context) {
var req proto.CreateUserReq
var rsp proto.Response
@@ -45,10 +97,15 @@ func (s *Service) CreateUser(c *gin.Context) {
rsp.ErrRsp(c, -2, err.Error())
return
}
if err = authn.DefaultStore.Create(req.Username, password, authn.Role(req.Role)); err != nil {
permissions, err := parsePermissions(req.Permissions)
if err != nil {
rsp.ErrRsp(c, -3, err.Error())
return
}
if err = authn.DefaultStore.Create(req.Username, password, authn.Role(req.Role), permissions...); err != nil {
rsp.ErrRsp(c, -4, err.Error())
return
}
rsp.OkRsp(c)
log.Infof("user created: %s (%s)", req.Username, req.Role)
}
@@ -56,7 +113,7 @@ func (s *Service) CreateUser(c *gin.Context) {
func (s *Service) UpdateUser(c *gin.Context) {
var req proto.UpdateUserReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil || (req.Role == nil && req.Enabled == nil) {
if err := proto.ParseFormRequest(c, &req); err != nil || (req.Role == nil && req.Enabled == nil && req.Permissions == nil) {
rsp.ErrRsp(c, -1, "invalid parameters")
return
}
@@ -66,13 +123,58 @@ func (s *Service) UpdateUser(c *gin.Context) {
role := authn.Role(*req.Role)
patch.Role = &role
}
if req.Permissions != nil {
permissions, err := parsePermissions(*req.Permissions)
if err != nil {
rsp.ErrRsp(c, -2, err.Error())
return
}
patch.Permissions = &permissions
}
username := c.Param("username")
if _, err := authn.DefaultStore.Update(principal.Username, username, patch); err != nil {
// The store hands back the account as it was before and after this patch,
// read under one lock, so a role change is handled exactly like editing the
// permission set: the role resolves to its implicit grants and the
// difference drives the refresh.
previous, updated, err := authn.DefaultStore.Update(principal.Username, username, patch)
if err != nil {
rsp.ErrRsp(c, -2, err.Error())
return
}
middleware.RevokeUserSessions(username)
rsp.OkRsp(c)
// A disabled account reaches nothing, whatever it stores.
previousPermissions := enabledPermissions(previous)
currentPermissions := enabledPermissions(updated)
rotated, rotateErr := rotateAPIKeyIfLost(previousPermissions, currentPermissions, req.RotateMcpKey)
if updated.TokenVersion != previous.TokenVersion {
// Disabling the account is the only change that invalidates the login.
middleware.RevokeUserSessions(username)
} else {
// Every other change keeps the login valid: drop only the long-lived
// connections whose in-memory state depends on what changed, and let the
// frontend refresh its account state in place. Revoked grants have to
// reconnect to stop using the feature, and granted ones have to reconnect
// to pick up capabilities cached at connect time (the console socket
// remembers whether it is read-only). Unrelated connections stay
// untouched. A save that changes nothing stays silent.
changed := permissionsChanged(previousPermissions, currentPermissions)
roleChanged := updated.Role != previous.Role
if (roleChanged || len(changed) > 0) && SessionNotifier != nil {
// The role itself is rendered too (user management, the role label),
// so the frontend refreshes even when the grants happen to match.
// The notifier writes to every socket of the user, each with its own
// write deadline; one stuck connection must not hold this request.
go SessionNotifier(username, "auth", "refresh")
}
if channels := channelsForPermissions(changed); len(channels) > 0 {
middleware.RefreshUserSessions(username, channels...)
}
}
if rotateErr != nil {
rsp.ErrRsp(c, -3, rotateErr.Error())
return
}
rsp.OkRspWithData(c, &proto.UpdateUserRsp{McpKeyRotated: rotated})
log.Infof("user updated: %s", username)
}
@@ -80,12 +182,19 @@ func (s *Service) DeleteUser(c *gin.Context) {
var rsp proto.Response
principal, _ := middleware.CurrentPrincipal(c)
username := c.Param("username")
previous, _ := authn.DefaultStore.Get(username)
if err := authn.DefaultStore.Delete(principal.Username, username); err != nil {
rsp.ErrRsp(c, -1, err.Error())
return
}
middleware.RevokeUserSessions(username)
rsp.OkRsp(c)
// DELETE carries no body, so the choice travels in the query string.
rotated, err := rotateAPIKeyIfLost(enabledPermissions(previous), nil, c.Query("rotateMcpKey") == "true")
if err != nil {
rsp.ErrRsp(c, -2, err.Error())
return
}
rsp.OkRspWithData(c, &proto.UpdateUserRsp{McpKeyRotated: rotated})
log.Infof("user deleted: %s", username)
}
@@ -115,6 +224,77 @@ func (s *Service) ChangeUserPassword(c *gin.Context) {
log.Infof("password reset for user: %s", username)
}
func enabledPermissions(user *authn.User) []authn.Permission {
if user == nil || !user.Enabled {
return nil
}
return authn.EffectivePermissions(user)
}
// permissionsChanged returns the grants that were added or removed. Both
// directions matter: a revoked grant must stop reaching the feature, while a
// granted one must reconnect to pick up state cached per connection.
func permissionsChanged(oldPermissions, newPermissions []authn.Permission) []authn.Permission {
changed := make([]authn.Permission, 0, len(oldPermissions))
for _, old := range oldPermissions {
if !authn.Has(newPermissions, old) {
changed = append(changed, old)
}
}
for _, current := range newPermissions {
if !authn.Has(oldPermissions, current) {
changed = append(changed, current)
}
}
return changed
}
// channelsForPermissions maps permissions that changed to the long-lived
// connection channels that must reconnect to take effect. Permissions without
// connection state (library, network, ...) are enforced per-request and need no
// teardown.
func channelsForPermissions(permissions []authn.Permission) []string {
channels := make([]string, 0, 2)
for _, permission := range permissions {
switch permission {
case authn.PermConsoleView:
// Losing the console grant closes the video stream, and gaining it (an
// account promoted to administrator) keeps every console connection
// from running under the grants it cached at connect time.
channels = append(channels, middleware.SessionChannelConsole, middleware.SessionChannelStream)
case authn.PermConsoleControl:
channels = append(channels, middleware.SessionChannelConsole)
case authn.PermTerminal:
channels = append(channels, middleware.SessionChannelTerminal)
case authn.PermAi:
channels = append(channels, middleware.SessionChannelAi)
}
}
return channels
}
func parsePermissions(raw []string) ([]authn.Permission, error) {
if raw == nil {
return nil, nil
}
permissions := make([]authn.Permission, 0, len(raw))
for _, item := range raw {
permissions = append(permissions, authn.Permission(item))
}
if err := authn.ValidatePermissions(permissions); err != nil {
return nil, err
}
return permissions, nil
}
func permissionStrings(permissions []authn.Permission) []string {
strings := make([]string, 0, len(permissions))
for _, permission := range permissions {
strings = append(strings, string(permission))
}
return strings
}
func decodePassword(encrypted string) (string, error) {
password, err := utils.DecodeDecrypt(encrypted)
if err != nil || password == "" {

View File

@@ -0,0 +1,53 @@
package auth
import (
"errors"
"testing"
"NanoKVM-Server/authn"
)
func TestRotateAPIKeyOnlyWhenLostAndRequested(t *testing.T) {
calls := 0
failing := false
original := APIKeyRotator
APIKeyRotator = func() error {
calls++
if failing {
return errors.New("disk full")
}
return nil
}
defer func() { APIKeyRotator = original }()
withMcp := []authn.Permission{authn.PermConsoleView, authn.PermMcp}
without := []authn.Permission{authn.PermConsoleView}
cases := []struct {
name string
previous, current []authn.Permission
requested bool
wantRotated bool
}{
{"kept grant", withMcp, withMcp, true, false},
{"never held", without, without, true, false},
{"lost, kept by request", withMcp, without, false, false},
{"lost, rotation requested", withMcp, without, true, true},
{"deleted, rotation requested", withMcp, nil, true, true},
}
for _, tc := range cases {
calls = 0
rotated, err := rotateAPIKeyIfLost(tc.previous, tc.current, tc.requested)
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if rotated != tc.wantRotated || calls != map[bool]int{false: 0, true: 1}[tc.wantRotated] {
t.Fatalf("%s: rotated=%v calls=%d", tc.name, rotated, calls)
}
}
failing = true
if rotated, err := rotateAPIKeyIfLost(withMcp, without, true); rotated || !errors.Is(err, errAPIKeyNotRotated) {
t.Fatalf("failed rotation: rotated=%v err=%v", rotated, err)
}
}

View File

@@ -2,6 +2,8 @@ package download
import (
"NanoKVM-Server/proto"
"NanoKVM-Server/service/storage"
"NanoKVM-Server/service/storage/diskfs"
"NanoKVM-Server/utils"
"bytes"
"context"
@@ -14,10 +16,8 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
@@ -37,12 +37,12 @@ const (
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
// transferTempPrefix marks the partial files of the uploads and downloads
// this service writes. The file manager hides them and refuses to delete
// them while a transfer runs (diskfs.IsTransferTemp), and a restart sweeps
// whatever a transfer left behind.
transferTempPrefix = diskfs.DownloadTempPrefix
// Keep /data from filling up completely: the exFAT metadata and the USB
// mass-storage export still need room while the image is written.
imageFreeReserve = 128 << 20
@@ -51,26 +51,75 @@ const (
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._-]+$`)
)
// maxUploadBytes caps a single upload even when the client lies about its
// length, so a runaway request cannot fill the card before the free space
// check has had its say.
const maxUploadBytes = 8 << 30
type Service struct {
downloadMutex sync.Mutex
downloadCancel context.CancelFunc
downloadDone chan struct{}
downloadStatus downloadStatus
downloadFile string
downloadPercentage string
downloadMutex sync.Mutex
downloadCancel context.CancelFunc
downloadDone chan struct{}
downloadStatus downloadStatus
downloadFile string
downloadLoaded int64
downloadTotal int64
}
func NewService() *Service {
// A running transfer cannot survive a server restart, so any remaining lock
// file is stale at this point.
_ = os.Remove(transferSentinelPath)
go removeStaleTransfers()
return &Service{downloadStatus: downloadStatusIdle}
}
// removeStaleTransfers deletes the temporary files of uploads and downloads
// that a restart interrupted. They are hidden and can be hundreds of
// megabytes, and hidden from the file manager, so leaving them behind would
// quietly fill the card. A file upload into the shared folder
// writes its temporary next to its target, so the whole tree is walked, not
// just the root.
//
// The walk covers the whole data partition, so it runs in the background, and
// a transfer may start while it does: only names one of our transfers would
// generate are collected, and each is removed only while no transfer runs. A
// transfer started after the walk writes a fresh name the walk never saw.
// While the target machine owns the partition it is read-only here, and the
// sweep waits for the next start.
func removeStaleTransfers() {
var stat unix.Statfs_t
if err := unix.Statfs(imageDirectory, &stat); err != nil || stat.Flags&unix.ST_RDONLY != 0 {
return
}
var stale []string
_ = filepath.Walk(imageDirectory, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if diskfs.IsTransferTemp(info.Name()) {
stale = append(stale, path)
}
return nil
})
for _, path := range stale {
if storage.TransferInProgress() {
log.Infof("leave stale transfer %s: a transfer is running", path)
continue
}
if err := os.Remove(path); err != nil {
log.Errorf("remove stale transfer %s failed: %s", path, err)
} else {
log.Infof("removed stale transfer %s", path)
}
}
}
func (s *Service) CancelDownloadImage(c *gin.Context) {
var rsp proto.Response
@@ -122,7 +171,8 @@ func (s *Service) beginDownload(file string, cancel context.CancelFunc) (chan st
s.downloadDone = done
s.downloadStatus = downloadStatusInProgress
s.downloadFile = file
s.downloadPercentage = ""
s.downloadLoaded = 0
s.downloadTotal = 0
return done, nil
}
@@ -136,12 +186,13 @@ func (s *Service) setDownloadFile(done chan struct{}, file string) {
}
}
func (s *Service) setDownloadProgress(done chan struct{}, percentage string) {
func (s *Service) setDownloadProgress(done chan struct{}, loaded, total int64) {
s.downloadMutex.Lock()
defer s.downloadMutex.Unlock()
if s.downloadDone == done {
s.downloadPercentage = percentage
s.downloadLoaded = loaded
s.downloadTotal = total
}
}
@@ -156,7 +207,8 @@ func (s *Service) finishDownload(done chan struct{}, status downloadStatus) {
s.downloadDone = nil
s.downloadStatus = status
s.downloadFile = ""
s.downloadPercentage = ""
s.downloadLoaded = 0
s.downloadTotal = 0
_ = os.Remove(transferSentinelPath)
s.downloadMutex.Unlock()
@@ -178,49 +230,6 @@ func (s *Service) ImageEnabled(c *gin.Context) {
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{Enabled: true})
}
// 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
}
}
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
}
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 {
@@ -247,19 +256,42 @@ func ensureImageSpace(dir string, contentLength int64) error {
return nil
}
// imageSpaceLimit is how many bytes a download may write into dir: its
// announced length, which ensureImageSpace has already checked, or else what
// fits while keeping the reserve.
func imageSpaceLimit(dir string, contentLength int64) (int64, error) {
if contentLength >= 0 {
return contentLength, nil
}
var stat unix.Statfs_t
if err := unix.Statfs(dir, &stat); err != nil {
return 0, fmt.Errorf("cannot read free space of %s: %w", dir, err)
}
available := uint64(stat.Bavail) * uint64(stat.Bsize)
if available <= imageFreeReserve {
return 0, fmt.Errorf("not enough space in %s: %d MiB available", dir, available>>20)
}
return int64(available - imageFreeReserve), nil
}
func (s *Service) StatusImage(c *gin.Context) {
var rsp proto.Response
s.downloadMutex.Lock()
status := s.downloadStatus
file := s.downloadFile
percentage := s.downloadPercentage
loaded := s.downloadLoaded
total := s.downloadTotal
s.downloadMutex.Unlock()
rsp.OkRspWithData(c, &proto.StatusImageRsp{
Status: string(status),
File: file,
Percentage: percentage,
Percentage: utils.FormatPercentage(loaded, total),
Loaded: loaded,
Total: total,
})
}
@@ -267,6 +299,18 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
var rsp proto.Response
log.Debug("DownloadImageFile")
// Everything below lands on the data partition, which the target machine
// owns whenever the emulated disk is attached to it. Hold that ownership
// decision for the whole transfer: a mount must wait instead of remounting
// /data underneath an upload that is already streaming.
releaseDataPartitionWrite, err := storage.BeginDataPartitionWrite()
if err != nil {
rsp.ErrRsp(c, -1, err.Error())
return
}
defer releaseDataPartitionWrite()
expectedSHA256, err := parseSHA256(c.GetHeader("X-SHA256-Sum"))
if err != nil {
rsp.ErrRsp(c, -1, err.Error())
@@ -290,6 +334,11 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
s.finishDownload(done, finalStatus)
}()
// The whole body is written to the data partition, so cap the request even
// when the client lies about its length. This has to happen before the
// multipart reader is created: it keeps reading from the body it was handed.
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadBytes)
reader, err := c.Request.MultipartReader()
if err != nil {
log.Error("invalid multipart data")
@@ -313,7 +362,8 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
}
filename := part.FileName()
if err := validateISOFilename(filename); err != nil {
if err := validateImageFilename(filename); err != nil {
_ = part.Close()
log.Warnf("reject uploaded image %q: %s", filename, err)
rsp.ErrRsp(c, -1, err.Error())
return
@@ -322,7 +372,7 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
// 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-*")
out, err := os.CreateTemp(imageDirectory, transferTempPrefix+"upload-*")
if err != nil {
log.Errorf("cannot create temporary file in %s: %s", imageDirectory, err)
rsp.ErrRsp(c, -1, "cannot create temporary file")
@@ -332,33 +382,18 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
defer os.Remove(tempPath)
hasher := sha256.New()
lw := newLoggingWriter(io.MultiWriter(out, hasher), c.Request.ContentLength, func(percentage string) {
s.setDownloadProgress(done, percentage)
lw := utils.NewProgressWriter(io.MultiWriter(out, hasher), c.Request.ContentLength, func(loaded, total int64) {
s.setDownloadProgress(done, loaded, total)
})
defer lw.stopTicker()
// 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")
return
}
if !isSupportedImage(head[:headSize]) {
log.Warnf("reject uploaded image %q: %s", filename, errUnsupportedImage)
rsp.ErrRsp(c, -1, errUnsupportedImage.Error())
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))
// The name already said what this has to be: .iso or .img. The bytes
// are taken as they come; a file that turns out not to be an image
// fails when it is mounted, not while it is stored.
_, copyErr := io.Copy(lw, part)
lw.Stop()
partCloseErr := part.Close()
outCloseErr := out.Close()
if copyErr != nil || partCloseErr != nil || outCloseErr != nil {
log.Errorf("write uploaded image %q failed: %s", filename, errors.Join(copyErr, partCloseErr, outCloseErr))
rsp.ErrRsp(c, -1, "write failed")
return
}
@@ -370,8 +405,8 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
}
outPath := filepath.Join(imageDirectory, filename)
if err := os.Rename(tempPath, outPath); err != nil {
rsp.ErrRsp(c, -1, "cannot install uploaded image")
if err := storage.InstallImage(tempPath, outPath); err != nil {
rsp.ErrRsp(c, -1, err.Error())
return
}
@@ -387,20 +422,15 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
rsp.ErrRsp(c, -1, "file is required")
}
func validateISOFilename(filename string) error {
if filename == "" {
return errors.New("no filename")
// validateImageFilename accepts what the emulated disk can expose: ISO images
// and raw disk images. The name never reaches the filesystem unchecked, and
// the rules are the ones a FAT volume can hold, so a medium named the same way
// is reachable from the file manager too.
func validateImageFilename(filename string) error {
if !utils.IsMediaFile(filename) {
return errors.New("only .iso and .img files allowed")
}
if filepath.Base(filename) != filename || strings.Contains(filename, "..") {
return errors.New("invalid filename")
}
if !strings.HasSuffix(strings.ToLower(filename), ".iso") {
return errors.New("only .iso files allowed")
}
if !validISOFilename.MatchString(filename) {
return errors.New("invalid filename")
}
return nil
return utils.ValidateFileName(filename)
}
func (s *Service) DownloadImage(c *gin.Context) {
@@ -431,19 +461,30 @@ func (s *Service) DownloadImage(c *gin.Context) {
return
}
// The download runs after this handler returns, and its temporary file is
// on the same partition the raw medium exposes. Keep the partition reserved
// until the goroutine has installed or removed that file.
releaseDataPartitionWrite, err := storage.BeginDataPartitionWrite()
if err != nil {
rsp.ErrRsp(c, -1, err.Error())
return
}
ctx, cancel := context.WithCancel(context.Background())
done, err := s.beginDownload(req.File, cancel)
if err != nil {
releaseDataPartitionWrite()
cancel()
rsp.ErrRsp(c, -1, err.Error())
return
}
go func() {
defer releaseDataPartitionWrite()
defer cancel()
if err := s.downloadRemoteImage(ctx, req.File, expectedSHA256, filename, func(percentage string) {
s.setDownloadProgress(done, percentage)
if err := s.downloadRemoteImage(ctx, req.File, expectedSHA256, filename, func(loaded, total int64) {
s.setDownloadProgress(done, loaded, total)
}); err != nil {
if errors.Is(err, context.Canceled) {
log.Debug("Image download canceled")
@@ -488,7 +529,7 @@ func (s *Service) downloadRemoteImage(
rawURL string,
expectedSHA256 []byte,
filename string,
onProgress func(string),
onProgress func(loaded, total int64),
) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
@@ -505,7 +546,22 @@ func (s *Service) downloadRemoteImage(
return fmt.Errorf("download request returned status %d", resp.StatusCode)
}
tempFile, err := os.CreateTemp(imageDirectory, ".nanokvm-download-*")
// The response headers say how large the image is: refuse it now instead
// of filling the partition and failing at the end. An unknown length
// (chunked responses) skips the check and is caught by the write itself.
if err := ensureImageSpace(imageDirectory, resp.ContentLength); err != nil {
return err
}
// A response without a length is capped by the free space instead, so a
// server that never stops sending cannot fill the partition. One byte more
// than the cap is read to tell "exactly fits" from "does not fit".
limit, err := imageSpaceLimit(imageDirectory, resp.ContentLength)
if err != nil {
return err
}
tempFile, err := os.CreateTemp(imageDirectory, transferTempPrefix+"download-*")
if err != nil {
return fmt.Errorf("create temporary image failed: %w", err)
}
@@ -513,9 +569,9 @@ func (s *Service) downloadRemoteImage(
defer os.Remove(tempPath)
hasher := sha256.New()
lw := newLoggingWriter(io.MultiWriter(tempFile, hasher), resp.ContentLength, onProgress)
_, copyErr := io.Copy(lw, resp.Body)
lw.stopTicker()
lw := utils.NewProgressWriter(io.MultiWriter(tempFile, hasher), resp.ContentLength, onProgress)
written, copyErr := io.Copy(lw, io.LimitReader(resp.Body, limit+1))
lw.Stop()
closeErr := tempFile.Close()
if ctx.Err() != nil {
return ctx.Err()
@@ -526,6 +582,15 @@ func (s *Service) downloadRemoteImage(
if closeErr != nil {
return fmt.Errorf("close downloaded image failed: %w", closeErr)
}
if written > limit {
return fmt.Errorf("not enough space in %s for the downloaded image", imageDirectory)
}
// net/http already fails a body shorter than its Content-Length and cuts
// a longer one off, but an image that is not the size the server
// announced is not one to install either way.
if resp.ContentLength >= 0 && written != resp.ContentLength {
return fmt.Errorf("downloaded image is %d bytes, the server announced %d", written, resp.ContentLength)
}
if expectedSHA256 != nil && !bytes.Equal(hasher.Sum(nil), expectedSHA256) {
return errSHA256Mismatch
@@ -535,69 +600,9 @@ func (s *Service) downloadRemoteImage(
}
destPath := filepath.Join(imageDirectory, filename)
if err := os.Rename(tempPath, destPath); err != nil {
return fmt.Errorf("install downloaded image failed: %w", err)
if err := storage.InstallImage(tempPath, destPath); err != nil {
return err
}
return nil
}
type loggingWriter struct {
writer io.Writer
total atomic.Int64
totalSize int64
ticker *time.Ticker
done chan struct{}
stopOnce sync.Once
onProgress func(string)
}
func newLoggingWriter(writer io.Writer, totalSize int64, onProgress func(string)) *loggingWriter {
lw := &loggingWriter{
writer: writer,
totalSize: totalSize,
onProgress: onProgress,
}
lw.startTicker()
return lw
}
func (lw *loggingWriter) startTicker() {
lw.ticker = time.NewTicker(2500 * time.Millisecond)
lw.done = make(chan struct{})
go func() {
for {
select {
case <-lw.done:
return
case <-lw.ticker.C:
lw.updateProgress()
}
}
}()
}
func (lw *loggingWriter) stopTicker() {
if lw == nil {
return
}
lw.stopOnce.Do(func() {
lw.ticker.Stop()
close(lw.done)
})
}
func (lw *loggingWriter) updateProgress() {
if lw.totalSize <= 0 || lw.onProgress == nil {
return
}
percentage := float64(lw.total.Load()) / float64(lw.totalSize) * 100
lw.onProgress(fmt.Sprintf("%.2f%%", percentage))
}
func (lw *loggingWriter) Write(p []byte) (int, error) {
n, err := lw.writer.Write(p)
lw.total.Add(int64(n))
return n, err
}

View File

@@ -1,73 +0,0 @@
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")
}
}

View File

@@ -146,6 +146,18 @@ func (h *Hid) OpenNoLockWithRetry(timeout, delay time.Duration) error {
return openNoLockWithRetry(h.OpenNoLock, timeout, delay)
}
// ReopenAfterRebind reopens the HID devices after the USB gadget was
// re-enumerated. The hidg nodes take a moment to come back, so the reopen is
// retried. Devices built without HID (/boot/disable_hid) have nothing to open,
// and a failure here must not turn a successful gadget operation into an
// error, so it is only logged.
func ReopenAfterRebind() {
h := GetHid()
if err := h.OpenNoLockWithRetry(hidReopenTimeout, hidReopenRetryDelay); err != nil {
log.Errorf("reopen HID devices after USB rebind failed: %s", err)
}
}
func openNoLockWithRetry(open func() error, timeout, delay time.Duration) error {
if timeout <= 0 {
return open()

View File

@@ -8,9 +8,5 @@ import (
)
func New() mcpservice.Snapshotter {
return mcpcapture.NewWithCaptureLease(common.GetKvmVision(), func() (uint16, uint16) {
screen := common.GetScreen()
common.CheckScreen()
return screen.Width, screen.Height
}, vm.AcquireHdmiCaptureLeaseForRead)
return mcpcapture.NewWithCaptureLease(common.GetKvmVision(), common.InputSize, vm.AcquireHdmiCaptureLeaseForRead)
}

View File

@@ -135,6 +135,14 @@ func ensureAPIKey(cfg Config) (Config, error) {
return cfg, nil
}
// RotateAPIKey replaces the device-wide API key. The key is shared by every
// account that may read it, so once one of them loses the mcp grant the key it
// may have copied has to stop working.
func RotateAPIKey() error {
_, err := updateConfig(regenerateAPIKey)
return err
}
func regenerateAPIKey(cfg Config) (Config, error) {
key, err := generateAPIKey()
if err != nil {

View File

@@ -60,7 +60,7 @@ func (s *Service) Screenshot(c *gin.Context) {
func (s *Service) captureScreenshot(ctx context.Context, query ScreenshotQuery) ([]byte, ScreenshotMeta, *PicoclawError) {
width, height, quality := resolveScreenshotRequest(query)
screen := common.GetScreen()
sourceWidth, sourceHeight := common.InputSize()
common.CheckScreen()
releaseLease, claimFresh, leaseErr := s.acquireCaptureLease(ctx)
if leaseErr != nil {
@@ -94,8 +94,8 @@ func (s *Service) captureScreenshot(ctx context.Context, query ScreenshotQuery)
continue
}
return data, ScreenshotMeta{
SourceWidth: screen.Width,
SourceHeight: screen.Height,
SourceWidth: sourceWidth,
SourceHeight: sourceHeight,
CaptureWidth: width,
CaptureHeight: height,
Format: "jpeg",
@@ -108,8 +108,7 @@ func (s *Service) captureScreenshot(ctx context.Context, query ScreenshotQuery)
func resolveScreenshotRequest(query ScreenshotQuery) (uint16, uint16, uint16) {
screen := common.GetScreen()
width := screen.Width
height := screen.Height
width, height := common.InputSize()
quality := screen.Quality
if query.Format == "base64" {

View File

@@ -0,0 +1,294 @@
package storage
import (
"fmt"
"os"
"sort"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
"NanoKVM-Server/service/storage/diskfs"
)
// The shared files medium is the data partition block device handed to the
// target machine while this device keeps it mounted: two writers on one
// filesystem. The target machine's writes arrive through the emulated USB disk
// and land in this device's page cache for that block device - the very cache
// the mounted exFAT reads its directory data from, so the file data on this
// side already is what the target machine wrote. What does not follow is the
// metadata this side cached: a file the target grew keeps the size it had when
// it was first read, and a directory that grew keeps the cluster chain it was
// first walked with. Dropping the dentry and inode caches makes a listing see
// the target machine's version. The page cache is deliberately left alone: it
// holds that version already, and dropping it would only add card reads.
//
// Nothing tells this side that those block level writes happened. They bypass
// the VFS, so inotify and fanotify stay silent, and the partition's write
// counter in /proc/diskstats only moves once this side writes its own dirty
// pages back - which can be tens of seconds after the target wrote, or when
// the target itself asks the disk to flush. The watcher therefore refreshes on
// a timer while the target machine owns the partition; the interval only
// bounds how long the web UI can lag behind the target's own writeback.
const (
// partitionWatchInterval is how often the shared files view is refreshed
// while the target machine owns the partition. Reading one directory is
// cheap, so the interval is chosen to keep a listing from looking stuck,
// not to save work.
partitionWatchInterval = time.Second
// partitionWriteQuiet is how long a change keeps counting as a transfer in
// progress. The target machine flushes in bursts, and its own writeback can
// hold the final size update for tens of seconds (Linux flushes dirty
// metadata only after dirty_expire_centisecs), so a short quiet spell is
// not the end of a copy.
partitionWriteQuiet = 45 * time.Second
)
var (
partitionCacheMu sync.Mutex
partitionLastChange time.Time
// partitionWatchPaths is every directory the file browser is showing: the
// one it listed plus the folders it has expanded. A change inside a
// subdirectory does not move its parent's signature, so all of them have
// to be watched, not just the last one listed.
partitionWatchPaths []string
partitionListeners []func()
partitionWatchOnce sync.Once
)
// startPartitionWatcher starts the periodic refresh of the shared files view.
func startPartitionWatcher() {
partitionWatchOnce.Do(func() {
go watchPartitionChanges()
})
}
func watchPartitionChanges() {
var lastSignature string
var lastWatch []string
// While a transfer is in progress the UI labels zero-length entries as
// uploads. The quiet window ending is not a change on disk, so it is the
// one transition the signature cannot report: without its own
// notification, a finished copy keeps looking like an upload that never
// completes.
reportedWriting := false
for {
time.Sleep(partitionWatchInterval)
// Only the target machine's writes bypass this side's caches; while
// this device is the only writer, the filesystem's own caches are
// current and dropping them would be wasted work.
if !IsAttached(partitionDevice) {
lastSignature = ""
lastWatch = nil
reportedWriting = false
continue
}
invalidatePartitionCache()
watch := watchedDirectories()
signature := partitionSignature(watch)
// The browser changing which folders it shows is not a write: the
// signature is not comparable with the previous one, and treating the
// difference as a transfer keeps an empty file labelled as an upload
// forever. Re-baseline instead, without touching the quiet window.
if !sameDirectories(watch, lastWatch) {
lastWatch = watch
lastSignature = signature
continue
}
// The signature is taken after the drop, so it compares what a listing
// would show now. It is what tells a transfer in progress - the sizes
// grow - from an idle medium, and it is also what keeps an idle shared
// folder from making the web UI re-read it every second.
if signature != lastSignature {
lastSignature = signature
partitionCacheMu.Lock()
partitionLastChange = time.Now()
partitionCacheMu.Unlock()
reportedWriting = true
notifyPartitionListeners()
continue
}
if reportedWriting && !partitionWriting() {
reportedWriting = false
notifyPartitionListeners()
}
}
}
// watchedDirectories returns the directories to read, sorted so that a set the
// browser sends in another order is still the same set.
func watchedDirectories() []string {
partitionCacheMu.Lock()
paths := append([]string{}, partitionWatchPaths...)
partitionCacheMu.Unlock()
sort.Strings(paths)
return paths
}
func sameDirectories(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// SetPartitionWatchPaths tells the watcher which directories of the shared
// folder the web UI is showing: the one being listed and every folder it has
// expanded. A change inside a subdirectory does not move the folder's own
// signature, so each open folder is watched on its own. An empty list stops
// watching subdirectories.
func SetPartitionWatchPaths(paths []string) {
partitionCacheMu.Lock()
partitionWatchPaths = append([]string{}, paths...)
partitionCacheMu.Unlock()
}
// SubscribePartitionChanges registers fn for "the shared files view was
// refreshed" notifications: once per refresh while the target machine owns the
// partition. fn runs on the watcher goroutine.
func SubscribePartitionChanges(fn func()) {
partitionCacheMu.Lock()
defer partitionCacheMu.Unlock()
partitionListeners = append(partitionListeners, fn)
}
func notifyPartitionListeners() {
partitionCacheMu.Lock()
listeners := append([]func(){}, partitionListeners...)
partitionCacheMu.Unlock()
for _, fn := range listeners {
fn()
}
}
// partitionWriting reports whether the shared files changed within the quiet
// window. A zero length file listed in that state is a transfer in progress,
// not an empty file.
func partitionWriting() bool {
// Only the target machine's writes arrive without this side knowing, so a
// hint left over from the last time it owned the partition would mislead a
// file browser that is reading the medium itself now.
if !IsAttached(partitionDevice) {
return false
}
partitionCacheMu.Lock()
defer partitionCacheMu.Unlock()
return !partitionLastChange.IsZero() && time.Since(partitionLastChange) < partitionWriteQuiet
}
// invalidatePartitionCache drops the stale metadata caches. It is the entry
// point both for the watcher and for a listing, so a request never has to wait
// for the next tick.
func invalidatePartitionCache() {
if !IsAttached(partitionDevice) {
return
}
dropPartitionCaches()
}
// partitionSignature summarizes what a listing would show: the shared folder
// itself, which is where files are exchanged, and every directory the file
// browser has open. Names, sizes and timestamps are all compared, because a
// copy changes the size of an entry it already added.
func partitionSignature(paths []string) string {
var signature strings.Builder
signature.WriteString(directorySignature(mediaDirectory))
seen := map[string]bool{"/": true}
for _, browsed := range paths {
if browsed == "" || seen[browsed] {
continue
}
seen[browsed] = true
dir, err := diskfs.ResolveInside(mediaDirectory, browsed)
if err != nil {
continue
}
signature.WriteString(directorySignature(dir))
}
return signature.String()
}
func directorySignature(dir string) string {
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
var signature strings.Builder
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
continue // vanished between the directory read and the stat
}
fmt.Fprintf(&signature, "%s|%d|%d|%t\n", entry.Name(), info.Size(), info.ModTime().Unix(), entry.IsDir())
}
return signature.String()
}
// drop_caches values. The sysctl only accepts 1 to 4 (the 5.10 table caps it
// with extra2 = 4), so a combined value such as 6 is refused with EINVAL and
// drops nothing. Bit 4 is written once on its own instead: it sets a sticky
// flag in fs/drop_caches.c that keeps every later drop out of the kernel log,
// which is how a share lasting a day used to fill the log on the data
// partition.
const (
dropCachesQuiet = "4" // stop logging drops from now on
dropCachesDentry = "2" // dentries and inodes
dropCachesAllData = "3" // page cache, dentries and inodes
)
var dropCachesQuietOnce sync.Once
func dropCaches(value string) {
dropCachesQuietOnce.Do(func() {
writeDropCaches(dropCachesQuiet)
})
writeDropCaches(value)
}
func writeDropCaches(value string) {
if err := os.WriteFile("/proc/sys/vm/drop_caches", []byte(value), 0); err != nil {
log.Warnf("drop stale caches (%s) failed: %s", value, err)
}
}
// dropPartitionCaches drops the dentries and inodes, which is what makes a
// listing see the target machine's entries and sizes. Dirty pages survive a
// drop, so gadget writes still in flight are not lost.
func dropPartitionCaches() {
dropCaches(dropCachesDentry)
}
// dropAllPartitionCaches throws away everything this side cached of the data
// partition, page cache included: it runs when the partition stops being
// served to the target machine, where a stale page written back would land on
// top of what the target machine just wrote.
func dropAllPartitionCaches() {
dropCaches(dropCachesAllData)
}

View File

@@ -0,0 +1,120 @@
package storage
import (
"errors"
"fmt"
"os/exec"
"sync"
"time"
"github.com/sirupsen/logrus"
"golang.org/x/sys/unix"
)
// Only one side may write the data partition at a time. The emulated USB disk
// shares that partition with the target machine as a raw, writable disk, and
// while it does the target machine is its only writer, so /data is remounted
// read-only here: remounting keeps every writer on this side away from the
// filesystem, not just the file manager that checks openMedium first.
// Mounting an image read-only hands the partition back to this side, and so
// does releasing the medium. Every read-only to writable transition drops the
// cache built while the target owned the partition before writers are allowed
// back in; callers must not remount it writable first and clean up afterwards.
func SetDataWritable(writable bool) error {
var stat unix.Statfs_t
if err := unix.Statfs(mediaDirectory, &stat); err != nil {
return fmt.Errorf("stat %s: %w", mediaDirectory, err)
}
if (stat.Flags&unix.ST_RDONLY == 0) == writable {
return nil
}
option := "rw"
if !writable {
option = "ro"
} else {
// The target may have changed both file data and allocation metadata
// while it owned the block device. Invalidate that view before the
// filesystem becomes writable, on every path that hands it back.
// A remount does not recount exFAT's used clusters (counted only at
// mount), so free space stays stale and freeing what the target wrote
// can underflow that count until /data is mounted afresh.
dropAllPartitionCaches()
}
if err := exec.Command("mount", "-o", "remount,"+option, mediaDirectory).Run(); err != nil {
return fmt.Errorf("remount %s %s: %w", mediaDirectory, option, err)
}
// Never leave a writable /data behind for a target machine that is writing
// the same filesystem.
var check unix.Statfs_t
if err := unix.Statfs(mediaDirectory, &check); err != nil {
return fmt.Errorf("stat %s: %w", mediaDirectory, err)
}
if (check.Flags&unix.ST_RDONLY == 0) != writable {
return errors.New(mediaDirectory + " mount did not change")
}
return nil
}
// DataWritable reports whether this side may write the data partition now.
func DataWritable() bool {
var stat unix.Statfs_t
if err := unix.Statfs(mediaDirectory, &stat); err != nil {
return false
}
return stat.Flags&unix.ST_RDONLY == 0
}
// healFailureInterval throttles the warning a heal that keeps failing would
// otherwise write: the state is polled every few seconds, and a mount that
// cannot change would fill the log with the same line.
const healFailureInterval = time.Minute
var (
healFailureMu sync.Mutex
healFailureLast time.Time
)
// logHealFailure reports a failed heal, at most once per interval.
func logHealFailure(err error) {
healFailureMu.Lock()
defer healFailureMu.Unlock()
if time.Since(healFailureLast) < healFailureInterval {
return
}
healFailureLast = time.Now()
logrus.Warnf("heal data partition: %s", err)
}
// healDataPartition puts /data back to read-write when nothing is attached to
// it. Two things leave it read-only with no owner: a release whose remount
// failed, and the kernel itself switching the mount when the exFAT driver hit
// an error (the mount carries errors=remount-ro). In both the target machine
// cannot write the partition any more, so this side taking it back is safe -
// and without it every upload, download and folder would fail with EROFS
// until a reboot.
func healDataPartition() error {
mediumOperationMu.Lock()
defer mediumOperationMu.Unlock()
status, err := readDiskStatus()
if err != nil {
return err
}
if status.State == diskStatePartition {
return nil
}
return SetDataWritable(true)
}
// DataDiskAttached reports whether the emulated disk serves the whole data
// partition to the target machine, which is the state /data is read-only in.
func DataDiskAttached() bool {
return IsAttached(partitionDevice)
}

View File

@@ -0,0 +1,245 @@
// Package diskfs gives the file manager access to the filesystem inside a
// medium. A medium is a disk image stored on the data partition, so the only
// way in and out is a loop mount: the device kernel has exFAT and FAT built
// in, and ships mkfs.exfat plus busybox mkdosfs, so both creating and mounting
// image files stays in the kernel instead of a hand written FAT reader.
//
// The medium must not be attached to the target machine while it is mounted
// here. Callers enforce that (see service/storage); this package only tracks
// its own mounts so concurrent requests share one.
package diskfs
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"NanoKVM-Server/utils"
)
// Kind is the filesystem a medium holds.
type Kind string
const (
KindExFAT Kind = "exfat"
KindFAT32 Kind = "fat32"
KindFAT16 Kind = "fat16"
KindFAT12 Kind = "fat12"
KindISO9660 Kind = "iso9660"
KindUDF Kind = "udf"
KindUnknown Kind = "unknown"
)
// Browsable reports whether the file manager can list files inside the medium.
// ISO 9660 is readable but read-only and not part of v1.
func (k Kind) Browsable() bool {
switch k {
case KindExFAT, KindFAT32, KindFAT16, KindFAT12:
return true
default:
return false
}
}
// Optical reports whether the medium holds a disc filesystem, which is what a
// CD-ROM has to carry: the gadget reports 2048 byte sectors for it.
func (k Kind) Optical() bool {
return k == KindISO9660 || k == KindUDF
}
// FileSystem is the type name the kernel mount needs. vfat covers every FAT
// variant (msdos is the subset without long names).
func (k Kind) FileSystem() string {
switch k {
case KindExFAT:
return "exfat"
case KindFAT32, KindFAT16, KindFAT12:
return "vfat"
case KindISO9660:
return "iso9660"
case KindUDF:
return "udf"
default:
return ""
}
}
// Entry is one directory entry inside a medium.
type Entry struct {
Name string `json:"name"`
Size int64 `json:"size"`
IsDir bool `json:"isDir"`
ModTime time.Time `json:"modifiedAt"`
}
// Caps is what the device can actually do. Mounting a medium needs a loop
// device; the file manager reports a missing capability instead of failing
// with a bare exec error, because a custom build may not have one.
type Caps struct {
Loop bool `json:"loop"`
}
// ErrUnsupported reports a missing device capability.
var ErrUnsupported = errors.New("unsupported on this device")
// ErrBusy reports a medium that cannot be handed out the way the caller
// asked: a write meets a direct reader, or a direct read meets a writer.
var ErrBusy = errors.New("the medium is busy")
// Probe reports the device's media capabilities. The answer cannot change
// while the server runs, and the listing asks for it on every poll, so it is
// computed once.
func Probe() Caps {
probeOnce.Do(func() {
probedCaps = Caps{
Loop: lookPath("losetup") && pathExists("/dev/loop-control"),
}
})
return probedCaps
}
var (
probeOnce sync.Once
probedCaps Caps
)
// Err describes what is missing, or nil when the device can browse media.
func (c Caps) Err() error {
if !c.Loop {
return fmt.Errorf("%w: no loop device support", ErrUnsupported)
}
return nil
}
// Detect reads the boot sector and reports the filesystem, so the UI knows
// whether a medium can be browsed before anything is mounted.
func Detect(imagePath string) (Kind, error) {
file, err := os.Open(imagePath)
if err != nil {
return KindUnknown, err
}
defer func() {
_ = file.Close()
}()
boot := make([]byte, 512)
if _, err := io.ReadFull(file, boot); err != nil {
return KindUnknown, nil // too small to hold a filesystem
}
if string(boot[3:11]) == "EXFAT " {
return KindExFAT, nil
}
if boot[510] == 0x55 && boot[511] == 0xaa {
// A FAT32 volume also carries the legacy "FAT16" string at 0x36, so
// the FAT32 field has to be looked at first.
if fat := boot[0x52 : 0x52+8]; isFATLabel(fat) {
return fatKind(string(fat)), nil
}
if fat := boot[0x36 : 0x36+8]; isFATLabel(fat) {
return fatKind(string(fat)), nil
}
}
return detectOptical(file), nil
}
// opticalSectorSize is the sector size of a disc, and udfVRSDescriptors the
// number of volume structure descriptors looked at. A UDF disc keeps its
// volume recognition sequence right after any ISO 9660 descriptors, and a
// handful of sectors covers both.
const (
opticalSectorSize = 2048
udfVRSDescriptors = 32
)
// detectOptical walks the volume recognition area 32 KiB into the image. An
// ISO 9660 primary descriptor ("CD001") in the first slot is the common case;
// a pure UDF disc, which many recent installers ship as, has no ISO 9660
// descriptor at all and is only recognized by its NSR02/NSR03 descriptor.
func detectOptical(file io.ReaderAt) Kind {
id := make([]byte, 5)
for i := 0; i < udfVRSDescriptors; i++ {
offset := int64(0x8000 + i*opticalSectorSize + 1)
if _, err := file.ReadAt(id, offset); err != nil {
break
}
switch string(id) {
case "CD001":
if i == 0 {
return KindISO9660
}
case "NSR02", "NSR03":
return KindUDF
case "BEA01", "TEA01", "BOOT2", "CDW02":
default:
// The sequence ends at the first slot that is no descriptor.
return KindUnknown
}
}
return KindUnknown
}
func isFATLabel(label []byte) bool {
return len(label) == 8 && (string(label[:5]) == "FAT12" || string(label[:5]) == "FAT16" || string(label[:5]) == "FAT32")
}
func fatKind(label string) Kind {
switch label[:5] {
case "FAT12":
return KindFAT12
case "FAT16":
return KindFAT16
default:
return KindFAT32
}
}
func lookPath(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
func pathExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func trimOutput(out []byte) string {
const limit = 200
text := string(out)
if len(text) > limit {
text = text[:limit]
}
return text
}
// ResolveInside resolves rel inside root and refuses anything that escapes it.
// Callers validate paths before this, so a failure here means a bug.
func ResolveInside(root, rel string) (string, error) {
if rel == "" || rel == "/" {
return root, nil
}
full := filepath.Join(root, filepath.FromSlash(rel))
inside, err := filepath.Rel(root, full)
if err != nil || inside == ".." || strings.HasPrefix(inside, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("%w: %s escapes the medium", utils.ErrInvalidMediaPath, rel)
}
return full, nil
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
}

View File

@@ -0,0 +1,181 @@
package diskfs
import (
"errors"
"io"
"os"
"path/filepath"
"testing"
)
func writeImage(t *testing.T, descriptors ...string) string {
t.Helper()
image := make([]byte, 0x8000+(len(descriptors)+2)*opticalSectorSize)
for i, id := range descriptors {
copy(image[0x8000+i*opticalSectorSize+1:], id)
}
path := filepath.Join(t.TempDir(), "disc.iso")
if err := os.WriteFile(path, image, 0o644); err != nil {
t.Fatal(err)
}
return path
}
func TestDetectOptical(t *testing.T) {
tests := []struct {
name string
descriptors []string
want Kind
}{
{"iso9660", []string{"CD001", "CD001"}, KindISO9660},
{"iso9660 with udf bridge", []string{"CD001", "BEA01", "NSR02", "TEA01"}, KindISO9660},
{"pure udf 1.02", []string{"BEA01", "NSR02", "TEA01"}, KindUDF},
{"pure udf 2.x", []string{"BEA01", "NSR03", "TEA01"}, KindUDF},
{"extended area without nsr", []string{"BEA01", "TEA01"}, KindUnknown},
{"nothing", nil, KindUnknown},
{"nsr after a gap", []string{"BEA01", "", "NSR03"}, KindUnknown},
}
for _, tt := range tests {
kind, err := Detect(writeImage(t, tt.descriptors...))
if err != nil {
t.Errorf("%s: %v", tt.name, err)
continue
}
if kind != tt.want {
t.Errorf("%s: Detect = %q, want %q", tt.name, kind, tt.want)
}
if kind.Optical() != (tt.want == KindISO9660 || tt.want == KindUDF) {
t.Errorf("%s: Optical = %v", tt.name, kind.Optical())
}
}
}
func TestIsTransferTemp(t *testing.T) {
tests := map[string]bool{
".kvm-upload-0a1b2c3d": true,
".kvm-upload-00000000": true,
".kvm-upload-0A1B2C3D": false,
".kvm-upload-0a1b2c3": false,
".kvm-upload-notes.txt": false,
".nanokvm-upload-123456789": true,
".nanokvm-download-4294967295": true,
".nanokvm-download-": false,
".nanokvm-config": false,
".nanokvm-upload-12ab": false,
"ubuntu.iso": false,
}
for name, want := range tests {
if got := IsTransferTemp(name); got != want {
t.Errorf("IsTransferTemp(%q) = %v, want %v", name, got, want)
}
}
}
func TestListDirHidesTransferTemps(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"a.txt", ".kvm-upload-0a1b2c3d", ".nanokvm-download-42", ".nanokvm-mine"} {
if err := os.WriteFile(filepath.Join(root, name), nil, 0o644); err != nil {
t.Fatal(err)
}
}
entries, err := listDir(root, "/")
if err != nil {
t.Fatal(err)
}
var names []string
for _, entry := range entries {
names = append(names, entry.Name)
}
if len(names) != 2 || names[0] != ".nanokvm-mine" || names[1] != "a.txt" {
t.Errorf("listDir = %v, want [.nanokvm-mine a.txt]", names)
}
}
func TestOpenFileRefusesSymlinks(t *testing.T) {
outside := filepath.Join(t.TempDir(), "secret")
if err := os.WriteFile(outside, []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
root := t.TempDir()
if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "dir"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "file"), []byte("ok"), 0o644); err != nil {
t.Fatal(err)
}
if file, _, err := openFile(root, "link"); err == nil {
_ = file.Close()
t.Error("openFile followed a symlink")
}
if file, _, err := openFile(root, "dir"); err == nil {
_ = file.Close()
t.Error("openFile opened a directory")
}
file, entry, err := openFile(root, "file")
if err != nil {
t.Fatal(err)
}
defer file.Close()
data, _ := io.ReadAll(file)
if string(data) != "ok" || entry.Size != 2 {
t.Errorf("openFile read %q, size %d", data, entry.Size)
}
}
func TestMkdirDir(t *testing.T) {
root := t.TempDir()
if err := mkdirDir(root, "photos"); err != nil {
t.Fatalf("mkdirDir: %v", err)
}
if info, err := os.Stat(filepath.Join(root, "photos")); err != nil || !info.IsDir() {
t.Fatalf("photos not created as a directory: %v", err)
}
// Nested paths work the way the file manager builds them.
if err := os.Mkdir(filepath.Join(root, "parent"), 0o755); err != nil {
t.Fatal(err)
}
if err := mkdirDir(root, "parent/child"); err != nil {
t.Fatalf("mkdirDir nested: %v", err)
}
// An existing name is reported, not replaced. The sentinel lets a caller
// that recreates a whole tree treat it as success.
if err := mkdirDir(root, "photos"); !errors.Is(err, ErrExists) {
t.Errorf("mkdirDir on an existing directory = %v, want ErrExists", err)
}
if err := mkdirDir(root, "/"); err == nil {
t.Error("mkdirDir accepted the medium root")
}
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := mkdirDir(root, "notes.txt"); err == nil || errors.Is(err, ErrExists) {
t.Errorf("mkdirDir on a file = %v, want a plain not-a-directory error", err)
}
if err := mkdirDir(root, "../escape"); err == nil {
t.Error("mkdirDir accepted a path outside the medium")
}
// A symlinked directory is not a place to create anything: the entry would
// land wherever the link points, outside the medium.
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil {
t.Fatal(err)
}
if err := mkdirDir(root, "link/escape"); err == nil {
t.Error("mkdirDir followed a symlink out of the medium")
}
if _, err := os.Stat(filepath.Join(outside, "escape")); err == nil {
t.Error("mkdirDir created a directory outside the medium")
}
}

View File

@@ -0,0 +1,685 @@
package diskfs
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"github.com/sirupsen/logrus"
"golang.org/x/sys/unix"
"NanoKVM-Server/utils"
)
// MountRoot holds the loop mounts while a medium is browsed. It lives under
// /run next to the transfer lock: a reboot clears it, and it is not inside
// /data where the medium listing walks every file.
const MountRoot = "/run/nanokvm/media"
// loopDeviceLimit is the number of loop devices the kernel is configured with;
// scanning them all is cheaper than parsing losetup output.
const loopDeviceLimit = 8
// UploadPrefix marks a partially written upload inside a medium. It is deleted
// on failure, renamed into place on success, and never handed out as a file.
const UploadPrefix = ".kvm-upload-"
// DownloadTempPrefix is what the image downloader (service/download) names
// the file it is writing while the transfer runs, through os.CreateTemp with
// "upload-*" or "download-*" after it.
const DownloadTempPrefix = ".nanokvm-"
// IsTransferTemp reports whether name is exactly what one of our transfers
// names its partial file: UploadPrefix plus randomSuffix's eight hex digits,
// or DownloadTempPrefix plus the decimal number os.CreateTemp puts in place of
// the "*". A user's own file that merely starts the same way is not one.
func IsTransferTemp(name string) bool {
if rest, ok := strings.CutPrefix(name, UploadPrefix); ok {
return len(rest) == 8 && strings.Trim(rest, "0123456789abcdef") == ""
}
if rest, ok := strings.CutPrefix(name, DownloadTempPrefix); ok {
for _, kind := range []string{"upload-", "download-"} {
if digits, ok := strings.CutPrefix(rest, kind); ok {
return digits != "" && strings.Trim(digits, "0123456789") == ""
}
}
}
return false
}
// Image is a mounted medium. Handles are reference counted, because a download
// may stream for minutes while the file list is refreshed, and both have to
// see the same mount.
type Image interface {
List(rel string) ([]Entry, error)
Open(rel string) (io.ReadSeekCloser, Entry, error)
Write(rel string, r io.Reader) (int64, error)
Mkdir(rel string) error
Remove(rel string) error
StatFS() (total, available uint64, err error)
Close() error
}
type mount struct {
path string
mountpoint string
loopDevice string
writable bool
refs int
}
var (
mountsMu sync.Mutex
mounts = map[string]*mount{}
// readers counts direct readers of a medium's file: an image download
// streams the file itself rather than a mount. A writable mount would
// tear the stream those readers see, so it is refused while any of them
// is active. Guarded by mountsMu, so the check and the mount stay one step.
readers = map[string]int{}
)
// Mount opens the filesystem inside a medium and returns a handle to it. The
// caller is responsible for making sure the medium is not attached to the
// target machine at the same time: two writers on one filesystem is how an
// image gets destroyed.
func Mount(imagePath string, writable bool) (Image, error) {
if err := Probe().Err(); err != nil {
return nil, err
}
kind, err := Detect(imagePath)
if err != nil {
return nil, err
}
if !kind.Browsable() {
return nil, fmt.Errorf("%w: %s holds no filesystem the file manager can browse", ErrUnsupported, imagePath)
}
mountsMu.Lock()
defer mountsMu.Unlock()
if existing, ok := mounts[imagePath]; ok {
// Sharing a read-write mount for reading is fine; the other way round
// is not, because the mount was set up read only.
if writable && !existing.writable {
return nil, fmt.Errorf("medium is already open for reading")
}
existing.refs++
return &handle{mount: existing}, nil
}
// A direct reader holds no mount, so it does not show up above; a write
// would tear the stream it is reading.
if writable && readers[imagePath] > 0 {
return nil, fmt.Errorf("%w: %s is being read", ErrBusy, imagePath)
}
attached, err := attach(imagePath, kind, writable)
if err != nil {
return nil, err
}
attached.refs = 1
mounts[imagePath] = attached
return &handle{mount: attached}, nil
}
// AcquireReadPin marks a medium's file as being read directly, outside a
// mount, and refuses while the file manager holds the medium open for
// writing: the stream would otherwise serve a file that changes underneath
// it. The returned function releases the pin and is safe to call twice.
func AcquireReadPin(imagePath string) (func(), error) {
mountsMu.Lock()
defer mountsMu.Unlock()
if existing, ok := mounts[imagePath]; ok && existing.writable {
return nil, fmt.Errorf("%w: %s is open for writing", ErrBusy, imagePath)
}
readers[imagePath]++
var once sync.Once
return func() {
once.Do(func() {
mountsMu.Lock()
defer mountsMu.Unlock()
readers[imagePath]--
if readers[imagePath] <= 0 {
delete(readers, imagePath)
}
})
}, nil
}
// MountedImages lists the media the file manager currently holds open.
func MountedImages() []string {
mountsMu.Lock()
defer mountsMu.Unlock()
paths := make([]string, 0, len(mounts))
for path := range mounts {
paths = append(paths, path)
}
sort.Strings(paths)
return paths
}
// IsMounted reports whether the file manager currently holds a medium open.
func IsMounted(imagePath string) bool {
mountsMu.Lock()
defer mountsMu.Unlock()
_, ok := mounts[imagePath]
return ok
}
func attach(imagePath string, kind Kind, writable bool) (*mount, error) {
if err := os.MkdirAll(MountRoot, 0o700); err != nil {
return nil, fmt.Errorf("create %s: %w", MountRoot, err)
}
mountpoint := filepath.Join(MountRoot, mountName(imagePath))
if err := os.MkdirAll(mountpoint, 0o700); err != nil {
return nil, fmt.Errorf("create %s: %w", mountpoint, err)
}
loopDevice, err := attachLoop(imagePath, writable)
if err != nil {
_ = os.Remove(mountpoint)
return nil, err
}
// Neither exFAT nor FAT can hold a symlink, but the image is untrusted
// and the server runs as root: refusing to follow one keeps any path
// inside the mount from resolving outside it, whatever the filesystem.
flags := uintptr(unix.MS_NOEXEC | unix.MS_NOSUID | unix.MS_NODEV | unix.MS_NOSYMFOLLOW)
if !writable {
flags |= unix.MS_RDONLY
}
if err := unix.Mount(loopDevice, mountpoint, kind.FileSystem(), flags, ""); err != nil {
detachLoop(loopDevice)
_ = os.Remove(mountpoint)
return nil, fmt.Errorf("mount %s as %s: %w", imagePath, kind, err)
}
return &mount{
path: imagePath,
mountpoint: mountpoint,
loopDevice: loopDevice,
writable: writable,
}, nil
}
// mountName builds a filesystem safe directory name that still says which
// medium it belongs to.
func mountName(imagePath string) string {
sum := sha256.Sum256([]byte(imagePath))
base := filepath.Base(imagePath)
base = strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-', r == '_':
return r
default:
return '-'
}
}, base)
return base + "-" + hex.EncodeToString(sum[:4])
}
func attachLoop(imagePath string, writable bool) (string, error) {
device, err := freeLoopDevice()
if err != nil {
return "", err
}
args := []string{}
if !writable {
args = append(args, "-r")
}
args = append(args, device, imagePath)
if out, err := exec.Command("losetup", args...).CombinedOutput(); err != nil {
return "", fmt.Errorf("losetup %s: %v: %s", imagePath, err, trimOutput(out))
}
return device, nil
}
func detachLoop(device string) {
if device == "" {
return
}
if out, err := exec.Command("losetup", "-d", device).CombinedOutput(); err != nil {
logrus.Errorf("detach loop device %s failed: %s: %s", device, err, trimOutput(out))
}
}
func freeLoopDevice() (string, error) {
for i := 0; i < loopDeviceLimit; i++ {
name := fmt.Sprintf("loop%d", i)
device := "/dev/" + name
if !pathExists(device) {
continue
}
// This kernel only creates the loop attributes once the device has been
// bound, so a missing backing_file is a free device rather than an
// error.
backing, err := os.ReadFile("/sys/block/" + name + "/loop/backing_file")
if os.IsNotExist(err) {
return device, nil
}
if err == nil && strings.TrimSpace(string(backing)) == "" {
return device, nil
}
}
return "", errors.New("no free loop device")
}
type handle struct {
mount *mount
closed bool
mu sync.Mutex
}
func (h *handle) List(rel string) ([]Entry, error) {
return listDir(h.mount.mountpoint, rel)
}
func (h *handle) Open(rel string) (io.ReadSeekCloser, Entry, error) {
return openFile(h.mount.mountpoint, rel)
}
func (h *handle) Write(rel string, r io.Reader) (int64, error) {
if !h.mount.writable {
return 0, errors.New("medium is mounted read-only")
}
return writeFile(h.mount.mountpoint, rel, r)
}
func (h *handle) Remove(rel string) error {
return removeFile(h.mount.mountpoint, rel)
}
func (h *handle) Mkdir(rel string) error {
if !h.mount.writable {
return errors.New("medium is mounted read-only")
}
return mkdirDir(h.mount.mountpoint, rel)
}
func (h *handle) StatFS() (total, available uint64, err error) {
return utils.Statfs(h.mount.mountpoint)
}
// dirHandle is a medium that needs no loop device because it is already a
// directory on a mounted filesystem: the data partition. The emulated disk
// hands that same directory to the target machine, so the file manager reads
// and writes exactly what the target machine sees.
type dirHandle struct {
root string
}
// OpenDirectory exposes root as a medium. Callers must make sure the target
// machine is not using it at the same time; the file manager does that with
// the disk state.
func OpenDirectory(root string) (Image, error) {
info, err := os.Stat(root)
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("%s is not a directory", root)
}
return &dirHandle{root: root}, nil
}
func (h *dirHandle) List(rel string) ([]Entry, error) {
return listDir(h.root, rel)
}
func (h *dirHandle) Open(rel string) (io.ReadSeekCloser, Entry, error) {
return openFile(h.root, rel)
}
func (h *dirHandle) Write(rel string, r io.Reader) (int64, error) {
return writeFile(h.root, rel, r)
}
func (h *dirHandle) Remove(rel string) error {
return removeFile(h.root, rel)
}
func (h *dirHandle) Mkdir(rel string) error {
return mkdirDir(h.root, rel)
}
func (h *dirHandle) StatFS() (total, available uint64, err error) {
return utils.Statfs(h.root)
}
func (h *dirHandle) Close() error {
return nil
}
func listDir(root, rel string) ([]Entry, error) {
dir, err := ResolveInside(root, rel)
if err != nil {
return nil, err
}
dirents, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
entries := make([]Entry, 0, len(dirents))
for _, dirent := range dirents {
// A partial transfer is not a file yet, and deleting it from the web
// UI would lose the data being written.
if IsTransferTemp(dirent.Name()) {
continue
}
info, err := dirent.Info()
if err != nil {
continue
}
entries = append(entries, entryOf(info))
}
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].IsDir != entries[j].IsDir {
return entries[i].IsDir
}
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
})
return entries, nil
}
func openFile(root, rel string) (io.ReadSeekCloser, Entry, error) {
full, err := ResolveInside(root, rel)
if err != nil {
return nil, Entry{}, err
}
// The entry is opened before it is looked at, and a symlink is refused
// rather than followed, so what is checked is what gets streamed.
file, err := os.OpenFile(full, os.O_RDONLY|unix.O_NOFOLLOW, 0)
if err != nil {
return nil, Entry{}, err
}
info, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, Entry{}, err
}
if !info.Mode().IsRegular() {
_ = file.Close()
return nil, Entry{}, fmt.Errorf("%s is not a regular file", rel)
}
return file, entryOf(info), nil
}
// Write stores r as rel inside the medium and reports how many bytes landed.
// The bytes go to a hidden name first and are renamed into place, so a failed
// or cancelled upload never leaves a half written file where the target
// machine can see it.
func writeFile(root, rel string, r io.Reader) (int64, error) {
full, err := ResolveInside(root, rel)
if err != nil {
return 0, err
}
if _, err := os.Stat(full); err == nil {
return 0, fmt.Errorf("%s: %w", rel, ErrExists)
}
tempPath := filepath.Join(filepath.Dir(full), UploadPrefix+randomSuffix())
file, err := os.OpenFile(tempPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return 0, err
}
written, err := io.Copy(file, r)
if err == nil {
err = file.Sync()
}
if closeErr := file.Close(); err == nil {
err = closeErr
}
if err != nil {
_ = os.Remove(tempPath)
return 0, err
}
if err := os.Rename(tempPath, full); err != nil {
_ = os.Remove(tempPath)
return 0, err
}
return written, nil
}
// ErrExists reports a name that is already taken inside the medium.
var ErrExists = errors.New("already exists")
// mkdirDir creates rel as a directory inside the medium. It refuses to replace
// anything: a name the target machine or the user already put there is a
// mistake to report, not to overwrite.
func mkdirDir(root, rel string) error {
full, err := ResolveInside(root, rel)
if err != nil {
return err
}
if full == root {
return errors.New("refusing to create the medium root")
}
// ResolveInside is lexical. A directory the target machine put there can be
// a symlink elsewhere, and a create through it would land outside the
// medium; the parent is what has to be real, since the entry does not exist
// yet.
if err := ensureInside(root, filepath.Dir(full)); err != nil {
return err
}
if info, err := os.Stat(full); err == nil {
// A name taken by a file is not a folder that is already there: the
// caller's tree upload would otherwise treat it as success and fail on
// every file it puts inside.
if !info.IsDir() {
return fmt.Errorf("%s is not a directory", rel)
}
return fmt.Errorf("%s: %w", rel, ErrExists)
}
if err := os.Mkdir(full, 0o755); err != nil {
return fmt.Errorf("create directory %s: %w", rel, err)
}
return nil
}
// ensureInside refuses a directory whose real path leaves root through a
// symlink. Both sides are resolved, so a medium that is itself reached through
// a link still compares correctly.
func ensureInside(root, dir string) error {
realRoot, err := filepath.EvalSymlinks(root)
if err != nil {
return err
}
realDir, err := filepath.EvalSymlinks(dir)
if err != nil {
return err
}
inside, err := filepath.Rel(realRoot, realDir)
if err != nil || inside == ".." || strings.HasPrefix(inside, ".."+string(filepath.Separator)) {
return fmt.Errorf("%w: %s leaves the medium", utils.ErrInvalidMediaPath, dir)
}
return nil
}
func removeFile(root, rel string) error {
full, err := ResolveInside(root, rel)
if err != nil {
return err
}
if full == root {
return errors.New("refusing to remove the medium root")
}
// A directory goes with everything it holds: the web UI confirms the
// delete first, and answering an explicit yes with "directory not empty"
// helps no one. ResolveInside keeps the walk inside the medium.
return os.RemoveAll(full)
}
func (h *handle) Close() error {
h.mu.Lock()
defer h.mu.Unlock()
if h.closed {
return nil
}
h.closed = true
mountsMu.Lock()
defer mountsMu.Unlock()
h.mount.refs--
if h.mount.refs > 0 {
return nil
}
delete(mounts, h.mount.path)
return detach(h.mount)
}
func detach(m *mount) error {
var errs []error
if err := unix.Unmount(m.mountpoint, 0); err != nil {
// A medium the file manager still holds open by accident must not keep
// the loop device busy forever.
if lazyErr := unix.Unmount(m.mountpoint, unix.MNT_DETACH); lazyErr != nil {
errs = append(errs, fmt.Errorf("unmount %s: %w", m.mountpoint, lazyErr))
}
}
detachLoop(m.loopDevice)
_ = os.Remove(m.mountpoint)
return errors.Join(errs...)
}
// CleanupStale detaches every mount and loop device left behind under the
// mount root. At startup nothing can be mounted by this process yet, so
// whatever is there is a leftover from a crash, a restart or an update; at
// shutdown it releases the mounts this process still holds.
func CleanupStale() {
mountsMu.Lock()
defer mountsMu.Unlock()
for _, mountpoint := range staleMountpoints() {
if err := unix.Unmount(mountpoint, unix.MNT_DETACH); err != nil {
logrus.Errorf("cleanup stale mount %s failed: %s", mountpoint, err)
}
// The directory is ours to remove; a non-empty one is left alone.
_ = os.Remove(mountpoint)
}
for _, device := range loopsBackingMedia() {
detachLoop(device)
}
// Mount points are created per medium and only exist while it is mounted,
// so whatever is still in the root is a leftover. Removing a directory
// fails while something is mounted on it, which is the safety we want.
if entries, err := os.ReadDir(MountRoot); err == nil {
for _, entry := range entries {
if entry.IsDir() {
_ = os.Remove(filepath.Join(MountRoot, entry.Name()))
}
}
}
mounts = map[string]*mount{}
}
func staleMountpoints() []string {
data, err := os.ReadFile("/proc/mounts")
if err != nil {
return nil
}
var paths []string
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
// /proc escapes spaces in mount points; our names never contain any.
mountpoint := fields[1]
if mountpoint == MountRoot || strings.HasPrefix(mountpoint, MountRoot+"/") {
paths = append(paths, mountpoint)
}
}
return paths
}
// loopsBackingMedia finds loop devices whose backing file is a medium, so a
// leftover device is released even when its mount is already gone.
func loopsBackingMedia() []string {
var devices []string
for i := 0; i < loopDeviceLimit; i++ {
name := fmt.Sprintf("loop%d", i)
backing, err := os.ReadFile("/sys/block/" + name + "/loop/backing_file")
if err != nil {
continue
}
path := strings.TrimSpace(string(backing))
if path == "" {
continue
}
if path == utils.MediaDirectory || strings.HasPrefix(path, utils.MediaDirectory+"/") {
devices = append(devices, "/dev/"+name)
}
}
return devices
}
func entryOf(info os.FileInfo) Entry {
return Entry{
Name: info.Name(),
Size: info.Size(),
IsDir: info.IsDir(),
ModTime: info.ModTime(),
}
}
func randomSuffix() string {
buf := make([]byte, 4)
if _, err := rand.Read(buf); err != nil {
return "00000000"
}
return hex.EncodeToString(buf)
}

View File

@@ -0,0 +1,560 @@
package storage
import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"mime/multipart"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/proto"
"NanoKVM-Server/service/storage/diskfs"
"NanoKVM-Server/utils"
)
// File handlers work inside a medium that is not attached to the target
// machine. A medium the target machine owns is refused by openMedium: the two
// of us writing one filesystem is how it gets destroyed.
// requireFileWrite picks the grant the medium in the request needs. One path
// serves two media types: an image holds library content (library.write), while
// the shared partition is what the emulated disk hands to the target machine, so
// writing into it belongs to medium.attach. The route admits either grant and
// this is the only place the pair is narrowed, because which one applies is not
// visible to a path-based rule.
func requireFileWrite(c *gin.Context, mediaType string) bool {
permission := authn.PermLibraryWrite
if mediaType == proto.MediaTypePartition {
permission = authn.PermMediumAttach
}
principal, ok := middleware.CurrentPrincipal(c)
if !ok {
c.JSON(http.StatusUnauthorized, "unauthorized")
return false
}
if principal.Has(permission) {
return true
}
c.JSON(http.StatusForbidden, "forbidden")
return false
}
// requireLibraryWrite guards a write through the shared partition that changes
// the library. The library is every .iso and .img on the volume, wherever it
// was put (findImages walks all of /data), so creating, replacing or removing
// one through the partition changes it as much as the library endpoints do,
// and medium.attach alone must not reach that far.
func requireLibraryWrite(c *gin.Context) bool {
principal, ok := middleware.CurrentPrincipal(c)
if !ok {
c.JSON(http.StatusUnauthorized, "unauthorized")
return false
}
if principal.Has(authn.PermLibraryWrite) {
return true
}
c.JSON(http.StatusForbidden, "forbidden")
return false
}
// holdsMedia reports whether full is a disk image or a directory with one
// anywhere below it.
func holdsMedia(full string) (bool, error) {
info, err := os.Stat(full)
if err != nil {
if os.IsNotExist(err) {
return utils.IsMediaFile(full), nil
}
return false, err
}
if !info.IsDir() {
return utils.IsMediaFile(info.Name()), nil
}
found := false
err = filepath.WalkDir(full, func(_ string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if !entry.IsDir() && utils.IsMediaFile(entry.Name()) {
found = true
return filepath.SkipAll
}
return nil
})
return found, err
}
// uploadMaxBytes is a hard ceiling for a single upload, so a runaway request
// cannot fill the medium before the free space check has had its say.
const uploadMaxBytes = 8 << 30
func (s *Service) ListMediaFiles(c *gin.Context) {
var rsp proto.Response
mediaType := c.Query("type")
rel := c.Query("rel")
dir, err := utils.ValidateMediumPath(c.Query("path"))
if err != nil {
respondError(c, err)
return
}
if mediaType == proto.MediaTypePartition {
// The browser tells the watcher every directory it is showing: the one
// it listed plus the folders it has expanded. Watching only the last
// one left the others stale until a manual refresh.
SetPartitionWatchPaths(watchPaths(dir, c.Query("watch")))
invalidatePartitionCache()
}
// An image listing deliberately leaves that set alone: another browser may
// still be showing the shared folder, and clearing it would stop its
// updates until the next listing.
image, err := openMedium(mediaType, rel, false)
if err != nil {
respondError(c, err)
return
}
defer closeMedia(image)
entries, err := image.List(dir)
if err != nil {
respondError(c, fmt.Errorf("list %s: %w", dir, err))
return
}
files := make([]proto.MediaFile, 0, len(entries))
for _, entry := range entries {
files = append(files, proto.MediaFile{
Name: entry.Name,
Size: entry.Size,
IsDir: entry.IsDir,
Modified: entry.ModTime,
})
}
total, free, _ := image.StatFS()
rsp.OkRspWithData(c, &proto.GetMediaFilesRsp{
Path: dir,
Entries: files,
Free: free,
Total: total,
Writing: mediaType == proto.MediaTypePartition && partitionWriting(),
})
log.Debugf("list media files success, %s/%s total %d", rel, dir, len(files))
}
func (s *Service) DownloadMediaFile(c *gin.Context) {
var rsp proto.Response
rel := c.Query("rel")
entryPath, err := utils.ValidateMediumPath(c.Query("path"))
if err != nil {
respondError(c, err)
return
}
if entryPath == "/" {
rsp.ErrRsp(c, codeInvalidArgument, "a file path is required")
return
}
if c.Query("type") == proto.MediaTypePartition {
invalidatePartitionCache()
}
image, err := openMedium(c.Query("type"), rel, false)
if err != nil {
respondError(c, err)
return
}
defer closeMedia(image)
file, entry, err := image.Open(entryPath)
if err != nil {
respondError(c, fmt.Errorf("open %s: %w", entryPath, err))
return
}
defer func() {
_ = file.Close()
}()
// The download streams straight out of the mounted image; ServeContent adds
// range support and the headers a browser download needs.
c.Header("Content-Disposition", attachmentHeader(path.Base(entryPath)))
c.Header("Content-Type", "application/octet-stream")
http.ServeContent(c.Writer, c.Request, entry.Name, entry.ModTime, file)
}
func (s *Service) UploadMediaFile(c *gin.Context) {
var rsp proto.Response
// The medium travels in the query string so the multipart body holds
// nothing but the file: reading a form field would make net/http buffer the
// whole upload in memory before the medium could even be opened.
mediaType := c.Query("type")
rel := c.Query("rel")
dir, err := utils.ValidateMediumPath(c.Query("path"))
if err != nil {
respondError(c, err)
return
}
// Content-Length covers the multipart framing as well, so it is an upper
// bound on the file: good enough to refuse an upload that cannot fit.
size := c.Request.ContentLength
if !requireFileWrite(c, mediaType) {
return
}
image, err := openMedium(mediaType, rel, true)
if err != nil {
respondError(c, err)
return
}
defer closeMedia(image)
// A chunked request states no length, so the free space is what has to cap
// it instead: without that, an upload that never ends could fill the medium.
limit := int64(uploadMaxBytes)
limitedBySpace := false
if _, free, err := image.StatFS(); err == nil {
if size > 0 {
if uint64(size) > free {
respondError(c, fmt.Errorf("%w: the medium has %d MiB free", errNoSpace, free>>20))
return
}
} else if free < uint64(limit) {
limit = int64(free)
limitedBySpace = true
}
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
reader, err := c.Request.MultipartReader()
if err != nil {
rsp.ErrRsp(c, codeInvalidArgument, "invalid multipart request")
return
}
part, err := nextFilePart(reader)
if err != nil {
respondError(c, err)
return
}
defer func() {
_ = part.Close()
}()
name, err := uploadName(part.FileName())
if err != nil {
respondError(c, err)
return
}
target := name
if dir != "/" {
target = path.Join(dir, name)
}
// exFAT folds case with its own table, so the name in the request is not
// necessarily the file it replaces: check the on-disk spelling as well.
if mediaType == proto.MediaTypePartition {
touchesLibrary := utils.IsMediaFile(name)
if !touchesLibrary {
full, err := utils.CanonicalPathInside(mediaDirectory, target)
if err != nil {
respondError(c, err)
return
}
touchesLibrary = utils.IsMediaFile(full)
}
if touchesLibrary && !requireLibraryWrite(c) {
return
}
}
// An upload is a long transfer like any other: it takes the single job
// slot so the web UI can report progress and nothing else writes to the
// data partition while it runs.
done, err := s.beginJob(c.Request.Context(), target)
if err != nil {
respondError(c, err)
return
}
// The upload is read from rather than written to, so the progress writer
// only counts: the bytes go to the medium through the tee.
progress := utils.NewProgressWriter(io.Discard, size, func(loaded, total int64) {
s.setJobProgress(done, loaded, total)
})
defer progress.Stop()
written, err := image.Write(target, io.TeeReader(part, progress))
if err != nil {
s.finishJob(done, jobStateFailed, err.Error())
// The UI translates this one: refusing to overwrite is a normal answer
// to an upload, not a failure whose English text needs to be shown.
if errors.Is(err, diskfs.ErrExists) {
rsp.ErrRsp(c, codeAlreadyExists, err.Error())
return
}
var tooLarge *http.MaxBytesError
if limitedBySpace && errors.As(err, &tooLarge) {
respondError(c, fmt.Errorf("%w: the medium could not hold the upload", errNoSpace))
return
}
respondError(c, err)
return
}
s.finishJob(done, jobStateSuccess, "")
rsp.OkRspWithData(c, &proto.MediaFile{Name: name, Size: written})
log.Debugf("upload %s to medium %s success", target, rel)
}
func (s *Service) DeleteMediaFile(c *gin.Context) {
var req proto.MediaPathReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
return
}
if !requireFileWrite(c, req.Type) {
return
}
entryPath, err := utils.ValidateMediumPath(req.Path)
if err != nil {
respondError(c, err)
return
}
if entryPath == "/" {
rsp.ErrRsp(c, codeInvalidArgument, "a file path is required")
return
}
// Our own transfer temporaries are not files to manage while a transfer
// runs: deleting one its transfer is writing loses the data silently. One
// found with no transfer running was left by an interrupted one, and the
// user may clear it.
if name := path.Base(entryPath); diskfs.IsTransferTemp(name) && TransferInProgress() {
respondError(c, fmt.Errorf("%w: %s belongs to a running transfer", errMediumBusy, name))
return
}
// A directory is removed with everything below it, the temporary of an
// upload into it included.
if target, ok := s.activeJobTarget(); ok && pathWithin(target, entryPath) {
respondError(c, fmt.Errorf("%w: an upload into %s is running", errMediumBusy, entryPath))
return
}
// Deleting through the shared partition has no loop mount to keep the
// gadget away. Hold the operation lock until the unlink is done so a
// concurrent mount cannot attach the file after the attachment check.
mediumOperationMu.Lock()
defer mediumOperationMu.Unlock()
// A file the target machine is being served as its medium must not vanish
// under it: the gadget keeps reading the inode it opened, so the target
// machine would be left with a medium whose contents are already gone. An
// image the file manager has mounted is just as much in use. A directory
// is removed recursively, so the check covers everything below the path as
// well. The path is resolved to its on-disk spelling first: exFAT folds
// case, so "IMAGES" is the directory the attached "images/win.iso" is in.
if req.Type == proto.MediaTypePartition {
full, err := utils.CanonicalPathInside(mediaDirectory, entryPath)
if err != nil {
respondError(c, err)
return
}
// Not knowing what the gadget serves is no reason to assume nothing.
status, err := readDiskStatus()
if err != nil {
respondError(c, fmt.Errorf("refusing to delete %s: %w", entryPath, err))
return
}
if err := deleteGuard(full, status.Backing, diskfs.MountedImages()); err != nil {
respondError(c, err)
return
}
touchesLibrary, err := holdsMedia(full)
if err != nil {
respondError(c, fmt.Errorf("refusing to delete %s: %w", entryPath, err))
return
}
if touchesLibrary && !requireLibraryWrite(c) {
return
}
}
image, err := openMediumLocked(req.Type, req.Rel, true)
if err != nil {
respondError(c, err)
return
}
defer closeMedia(image)
if err := image.Remove(entryPath); err != nil {
respondError(c, fmt.Errorf("remove %s: %w", entryPath, err))
return
}
rsp.OkRsp(c)
log.Debugf("delete %s from medium %s success", entryPath, req.Rel)
}
// MkdirMediaFile creates a directory inside a medium. The web file manager
// offers it next to the upload button: a folder the user can drop files into
// is how a disk that started empty becomes organised. It is a write like any
// other, so it goes through openMedium and is refused while the target machine
// owns the partition.
func (s *Service) MkdirMediaFile(c *gin.Context) {
var req proto.MediaPathReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
return
}
if !requireFileWrite(c, req.Type) {
return
}
entryPath, err := utils.ValidateMediumPath(req.Path)
if err != nil {
respondError(c, err)
return
}
if entryPath == "/" {
rsp.ErrRsp(c, codeInvalidArgument, "a directory path is required")
return
}
image, err := openMedium(req.Type, req.Rel, true)
if err != nil {
respondError(c, err)
return
}
defer closeMedia(image)
if err := image.Mkdir(entryPath); err != nil {
if errors.Is(err, diskfs.ErrExists) {
if !req.ExistsOK {
rsp.ErrRsp(c, codeAlreadyExists, fmt.Sprintf("%s already exists", entryPath))
return
}
} else {
respondError(c, fmt.Errorf("create %s: %w", entryPath, err))
return
}
}
// The shared partition has a metadata cache the listing reads; the
// directory has to show up in the next list.
if req.Type == proto.MediaTypePartition {
invalidatePartitionCache()
}
rsp.OkRsp(c)
log.Debugf("create directory %s in medium %s success", entryPath, req.Rel)
}
// maxWatchPaths caps the directories a single browser can ask the watcher to
// read every second. The file manager expands a handful at most.
const maxWatchPaths = 64
// watchPaths reads the directories the file browser is showing: the one being
// listed plus every folder it has expanded. Anything that does not name a
// directory inside the medium is dropped.
func watchPaths(listed string, raw string) []string {
paths := []string{listed}
seen := map[string]bool{listed: true}
var candidates []string
if raw = strings.TrimSpace(raw); raw != "" {
if err := json.Unmarshal([]byte(raw), &candidates); err != nil {
// An older client sent a comma separated list; a folder name may
// contain a comma, which is exactly why JSON is used now.
candidates = strings.Split(raw, ",")
}
}
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" {
continue
}
cleaned, err := utils.ValidateMediumPath(candidate)
if err != nil || seen[cleaned] {
continue
}
seen[cleaned] = true
paths = append(paths, cleaned)
if len(paths) >= maxWatchPaths {
break
}
}
return paths
}
// nextFilePart walks the multipart body to the uploaded file, skipping any
// field that came before it.
func nextFilePart(reader *multipart.Reader) (*multipart.Part, error) {
for {
part, err := reader.NextPart()
if err == io.EOF {
return nil, fmt.Errorf("%w: no file in the request", utils.ErrInvalidMediaPath)
}
if err != nil {
return nil, fmt.Errorf("read multipart request: %w", err)
}
if part.FormName() != "file" {
_ = part.Close()
continue
}
return part, nil
}
}
// uploadName reduces the browser supplied file name to something the medium
// can hold. The client picks it, so it is treated as untrusted.
func uploadName(raw string) (string, error) {
name := filepath.Base(raw)
if err := utils.ValidateFileName(name); err != nil {
return "", err
}
return name, nil
}
func closeMedia(image io.Closer) {
if err := image.Close(); err != nil {
log.Errorf("close medium failed: %s", err)
}
}

View File

@@ -0,0 +1,72 @@
package storage
import (
"reflect"
"testing"
)
func TestWatchPaths(t *testing.T) {
tests := []struct {
name string
listed string
raw string
want []string
}{
{
name: "listed directory is always watched",
listed: "/",
raw: "",
want: []string{"/"},
},
{
// The handler passes the already cleaned path it is listing; the
// browser's expanded folders are cleaned the same way.
name: "expanded folders are added",
listed: "share",
raw: `["/share/a","/share/b"]`,
want: []string{"share", "share/a", "share/b"},
},
{
// A comma is legal in a folder name, which is why the list is JSON.
name: "a comma stays inside the name",
listed: "/",
raw: `["/a,b"]`,
want: []string{"/", "a,b"},
},
{
name: "duplicates and blanks are dropped",
listed: "share",
raw: `["/share","","/share/a","/share/a"]`,
want: []string{"share", "share/a"},
},
{
name: "a plain comma list still parses",
listed: "/",
raw: "/a,/b",
want: []string{"/", "a", "b"},
},
{
name: "paths that escape the medium are dropped",
listed: "/",
raw: `["../etc","/share/../../etc"]`,
want: []string{"/"},
},
}
for _, tt := range tests {
if got := watchPaths(tt.listed, tt.raw); !reflect.DeepEqual(got, tt.want) {
t.Errorf("%s: watchPaths(%q, %q) = %v, want %v", tt.name, tt.listed, tt.raw, got, tt.want)
}
}
}
func TestWatchPathsCap(t *testing.T) {
raw := ""
for i := 0; i < maxWatchPaths+10; i++ {
raw += "/dir" + string(rune('a'+i%26)) + string(rune('a'+i/26)) + ","
}
if got := len(watchPaths("/", raw)); got > maxWatchPaths {
t.Errorf("watchPaths kept %d entries, want at most %d", got, maxWatchPaths)
}
}

View File

@@ -0,0 +1,391 @@
package storage
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"unicode/utf8"
log "github.com/sirupsen/logrus"
"NanoKVM-Server/service/hid"
"NanoKVM-Server/utils"
)
// The emulated USB disk is one LUN of the configfs mass storage function.
// Everything here is sysfs poking, and the kernel owns the rules:
//
// - writing an empty value to lun.0/file detaches the medium (the target
// machine sees a media removal), writing a path attaches one;
// - the read-only flag can only change while no medium is attached, so a
// mount always goes eject -> flags -> inquiry -> file;
// - a host that locked the medium (prevent_medium_removal, which Windows
// sets while the disk is mounted) makes both fail with EBUSY.
const (
gadgetRoot = "/sys/kernel/config/usb_gadget/g0"
gadgetUDC = gadgetRoot + "/UDC"
gadgetUDCDir = "/sys/class/udc"
massStorageFunc = gadgetRoot + "/functions/mass_storage.disk0"
massStorageLink = gadgetRoot + "/configs/c.1/mass_storage.disk0"
lunCDRomFlag = massStorageFunc + "/lun.0/cdrom"
lunMountDevice = massStorageFunc + "/lun.0/file"
lunInquiry = massStorageFunc + "/lun.0/inquiry_string"
lunROFlag = massStorageFunc + "/lun.0/ro"
// usbProductAttr is the device name the target machine shows. An image is
// named after its file, the shared partition keeps the product name.
usbProductAttr = gadgetRoot + "/strings/0x409/product"
)
const (
usbProductName = "NanoKVM"
// The SCSI inquiry product field holds 16 bytes, the USB product string 126.
inquiryProductSize = 16
usbProductSize = 126
)
// ErrMediumLocked reports that the target machine is still using the medium.
// It is a normal condition, not a failure: the user has to eject the disk
// there before the web UI can take it back.
var ErrMediumLocked = errors.New("the target machine is using the medium")
type diskState string
const (
diskStateNone diskState = "none" // no medium attached
diskStateImage diskState = "image" // an image file is attached
diskStatePartition diskState = "partition" // the whole data partition is attached
diskStateUnsupported diskState = "unsupported" // no mass storage function (virtual disk off, or HID-only)
)
// diskStatus is what the gadget exposes to the target machine right now. The
// backing file is the single source of truth: both the UI and the file manager
// read it instead of keeping their own idea of what is mounted.
type diskStatus struct {
State diskState
Backing string
HidOnly bool
}
// lunConfig is the LUN configuration a mount request results in.
type lunConfig struct {
backing string
ro bool
cdrom bool
}
func readDiskStatus() (diskStatus, error) {
mode, err := hid.GetMode()
hidOnly := err == nil && mode == hid.ModeHidOnly
// Switching the virtual disk off removes the link that exposes the
// function to the target machine, while the function itself stays behind
// with whatever medium was attached to it. The link is therefore what says
// whether a medium would actually be visible, and without it a mount would
// look like it worked and change nothing.
if !pathExists(massStorageFunc) || !pathExists(massStorageLink) {
return diskStatus{State: diskStateUnsupported, HidOnly: hidOnly}, nil
}
config, err := readLunConfig()
if err != nil {
return diskStatus{}, err
}
status := diskStatus{HidOnly: hidOnly}
switch {
case config.backing == "":
status.State = diskStateNone
case config.backing == partitionDevice:
status.State = diskStatePartition
status.Backing = config.backing
default:
status.State = diskStateImage
status.Backing = config.backing
// A running gadget may still report an alias used before this process
// started. Normalize it too, so every operation compares the same
// exFAT directory entry rather than just the same string.
if canonical, err := utils.CanonicalMediaPath(config.backing); err == nil {
status.Backing = canonical
}
}
return status, nil
}
func pathExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func readLunConfig() (lunConfig, error) {
backing, err := readLunAttr(lunMountDevice)
if err != nil {
return lunConfig{}, err
}
ro, err := readLunBool(lunROFlag)
if err != nil {
return lunConfig{}, err
}
cdrom, err := readLunBool(lunCDRomFlag)
if err != nil {
return lunConfig{}, err
}
return lunConfig{backing: backing, ro: ro, cdrom: cdrom}, nil
}
func readLunAttr(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read %s: %w", path, err)
}
return strings.TrimSpace(string(data)), nil
}
func readLunBool(path string) (bool, error) {
value, err := readLunAttr(path)
if err != nil {
return false, err
}
return value == "1", nil
}
// eject detaches the medium, which the target machine sees as a media removal.
func eject() error {
// The medium goes first. While it is attached the kernel refuses ro but
// takes cdrom=0 and a new name, so resetting those first would relabel a
// medium the target machine still holds whenever the detach itself then
// fails with EBUSY. A zero length write never reaches the kernel, so an
// empty line is what actually detaches the medium.
if err := writeLunAttr(lunMountDevice, "\n"); err != nil {
if errors.Is(err, syscall.EBUSY) {
return ErrMediumLocked
}
return fmt.Errorf("eject medium: %w", err)
}
// The flags outlive the medium: the kernel only lets ro change while
// nothing is attached, and a stale CD-ROM flag would describe the next
// medium wrongly (a restart of the boot script resets them for the same
// reason). The device also keeps the name it was built with while nothing
// is attached.
if err := writeLunAttr(lunInquiry, inquiryData("USB Mass Storage")); err != nil {
log.Warnf("restore inquiry string failed: %s", err)
}
if err := writeLunAttr(usbProductAttr, usbProductName); err != nil {
log.Warnf("restore usb product failed: %s", err)
}
if err := writeLunAttr(lunROFlag, boolAttr(false)); err != nil {
log.Warnf("reset read-only flag failed: %s", err)
}
if err := writeLunAttr(lunCDRomFlag, boolAttr(false)); err != nil {
log.Warnf("reset cdrom flag failed: %s", err)
}
return nil
}
// attach runs the sequence the kernel requires. The read-only flag can only be
// written while no medium is attached, and both it and the CD-ROM flag force a
// detach of their own, so the order is fixed and every flag is written on
// every mount: leaving a stale cdrom=1 behind is how an image file ends up
// presented to the target as a CD-ROM.
func attach(backing string, cdrom, ro bool) error {
if backing == "" {
return errors.New("empty backing file")
}
if cdrom {
// CD-ROM media is always read only.
ro = true
}
// The target machine is the only writer of the data partition while the
// emulated disk serves it, so /data goes read-only here before it can write
// it. An image is served read-only instead, which leaves /data to this
// side, so that handover happens after the gadget carries the image.
if backing == partitionDevice {
if err := SetDataWritable(false); err != nil {
return fmt.Errorf("make %s read-only: %w", mediaDirectory, err)
}
}
if err := eject(); err != nil {
return err
}
if err := writeLunAttr(lunROFlag, boolAttr(ro)); err != nil {
return fmt.Errorf("set read-only flag: %w", err)
}
if err := writeLunAttr(lunCDRomFlag, boolAttr(cdrom)); err != nil {
return fmt.Errorf("set cdrom flag: %w", err)
}
if err := writeLunAttr(lunInquiry, inquiryData(mediumProduct(backing, cdrom))); err != nil {
return fmt.Errorf("set inquiry string: %w", err)
}
if err := writeLunAttr(usbProductAttr, mediumDeviceName(backing)); err != nil {
// The target machine is left with the previous device name instead of
// failing the mount: the medium itself is what matters.
log.Warnf("set usb product failed: %s", err)
}
if err := writeLunAttr(lunMountDevice, backing); err != nil {
if errors.Is(err, syscall.EBUSY) {
return ErrMediumLocked
}
return fmt.Errorf("attach %s: %w", backing, err)
}
if err := rebindUDC(); err != nil {
return err
}
if backing != partitionDevice {
if err := SetDataWritable(true); err != nil {
log.Warnf("make %s writable failed: %s", mediaDirectory, err)
}
}
return nil
}
// mediumProduct is the SCSI product string of the medium: an image is shown
// under its file name, the mount mode names the medium otherwise.
func mediumProduct(backing string, cdrom bool) string {
if name := imageMediumName(backing); name != "" {
return cutName(name, inquiryProductSize)
}
if cdrom {
return "USB CD/DVD-ROM"
}
return "USB Mass Storage"
}
// mediumDeviceName is the USB product string, which the target machine shows
// as the device name. The shared partition keeps the one it was built with.
func mediumDeviceName(backing string) string {
if name := imageMediumName(backing); name != "" {
return cutName(name, usbProductSize)
}
return usbProductName
}
// imageMediumName is the name a mounted image is shown under: its file name
// without the extension. The raw partition is not an image.
func imageMediumName(backing string) string {
if backing == partitionDevice {
return ""
}
return imageName(backing)
}
// imageName strips the extension from the file name of a medium.
func imageName(path string) string {
name := filepath.Base(path)
if extension := filepath.Ext(name); extension != "" {
name = strings.TrimSuffix(name, extension)
}
return name
}
// cutName cuts name to size bytes without leaving a broken UTF-8 rune behind:
// the inquiry field and the USB product string are both byte limited.
func cutName(name string, size int) string {
if len(name) <= size {
return name
}
name = name[:size]
for !utf8.ValidString(name) {
name = name[:len(name)-1]
}
return name
}
func writeLunAttr(path, value string) error {
if err := os.WriteFile(path, []byte(value), 0o666); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
func boolAttr(value bool) string {
if value {
return "1"
}
return "0"
}
func inquiryData(product string) string {
return fmt.Sprintf("%-8s%-16s%04x", "NanoKVM", product, 0x0520)
}
// unbindUDC tears the gadget down. The kernel clears the medium lock the
// target machine set (prevent_medium_removal) whenever the function is
// reconfigured, so this is what makes a forced eject possible: without it the
// kernel refuses to touch the medium while the target machine is holding it.
func unbindUDC() error {
if err := writeLunAttr(gadgetUDC, "\n"); err != nil {
return fmt.Errorf("unbind UDC: %w", err)
}
return nil
}
// rebindUDC re-enumerates the gadget. It drops and re-adds every function,
// keyboard and mouse included, which is why callers skip it when nothing
// changed.
func rebindUDC() error {
// The forced path unbinds the gadget first, so it can already be unbound
// here; the kernel answers that with ENODEV instead of doing anything.
if err := writeLunAttr(gadgetUDC, "\n"); err != nil && !errors.Is(err, syscall.ENODEV) {
return fmt.Errorf("unbind UDC: %w", err)
}
udc, err := firstUDC()
if err != nil {
return err
}
time.Sleep(100 * time.Millisecond)
if err := writeLunAttr(gadgetUDC, udc+"\n"); err != nil {
return fmt.Errorf("bind UDC %s: %w", udc, err)
}
return nil
}
func firstUDC() (string, error) {
entries, err := os.ReadDir(gadgetUDCDir)
if err != nil {
return "", fmt.Errorf("list UDC: %w", err)
}
for _, entry := range entries {
return entry.Name(), nil
}
return "", errors.New("no UDC available")
}
// withHIDLock closes the HID devices around a gadget operation, because
// re-enumerating drops them. The reopen is retried and never turns a
// successful operation into a failure: builds without HID (/boot/disable_hid)
// have nothing to open at all.
func withHIDLock(fn func() error) error {
h := hid.GetHid()
h.Lock()
h.CloseNoLock()
defer func() {
hid.ReopenAfterRebind()
h.Unlock()
}()
return fn()
}

View File

@@ -1,66 +1,42 @@
package storage
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/unix"
"NanoKVM-Server/proto"
"NanoKVM-Server/service/hid"
"NanoKVM-Server/service/storage/diskfs"
"NanoKVM-Server/utils"
)
const (
imageDirectory = "/data"
imageNone = "/dev/mmcblk0p3"
cdromFlag = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/cdrom"
mountDevice = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/file"
inquiryString = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/inquiry_string"
roFlag = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/ro"
usbProduct = "/sys/kernel/config/usb_gadget/g0/strings/0x409/product"
usbProductName = "NanoKVM"
// the SCSI inquiry product field holds 16 bytes, the USB product string 126
inquiryProductSize = 16
usbProductSize = 126
)
// The handlers in this file are the pre-2.6 image API. The web UI uses the
// media API instead, but these routes stay so scripts written against the
// documented endpoints keep working: same paths, same request shapes, same
// responses. They share the media implementation underneath, which is also
// where the guards they used to lack now live.
func (s *Service) GetImages(c *gin.Context) {
var rsp proto.Response
var images []string
err := filepath.Walk(imageDirectory, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
name := strings.ToLower(info.Name())
if strings.HasSuffix(name, ".iso") || strings.HasSuffix(name, ".img") {
images = append(images, path)
}
}
return nil
})
media, err := listMedia()
if err != nil {
rsp.ErrRsp(c, -2, "get images failed")
respondError(c, err)
return
}
rsp.OkRspWithData(c, &proto.GetImagesRsp{
Files: images,
})
log.Debugf("get images success, total %d", len(images))
files := make([]string, 0, len(media))
for _, item := range media {
if item.Type == proto.MediaTypeImage {
files = append(files, filepath.Join(mediaDirectory, filepath.FromSlash(item.Rel)))
}
}
rsp.OkRspWithData(c, &proto.GetImagesRsp{Files: files})
log.Debugf("get images success, total %d", len(files))
}
func (s *Service) MountImage(c *gin.Context) {
@@ -68,125 +44,41 @@ func (s *Service) MountImage(c *gin.Context) {
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
return
}
// The host owns the raw partition while the virtual disk serves it, so /data
// has to be read-only here before the host can write it.
if req.File == "" {
if err := SetDataWritable(false); err != nil {
log.Errorf("make %s read-only failed: %s", imageDirectory, err)
rsp.ErrRsp(c, -2, "make /data read-only failed")
return
}
}
// set the LUN flags, unmounting first so no image is ever served writable
if err := os.WriteFile(mountDevice, []byte("\n"), 0o666); err != nil {
log.Errorf("unmount file failed: %s", err)
rsp.ErrRsp(c, -2, "unmount image failed")
return
}
// ro flag
// set to 0 when unmount image
// set to 1 when mount image, the host can only read the image
roFlagValue := "0"
// An empty file keeps its original meaning: attach the whole data
// partition, which is what the emulated disk exposes by default.
backing := partitionDevice
if req.File != "" {
roFlagValue = "1"
}
if err := os.WriteFile(roFlag, []byte(roFlagValue), 0o666); err != nil {
log.Errorf("set ro flag failed: %s", err)
rsp.ErrRsp(c, -2, "set ro flag failed")
return
}
// cdrom flag
// set to 0 when unmount image
// set to 1 when mount image and the CD-ROM is enabled
cdromFlagValue := "0"
if req.File != "" && req.Cdrom {
cdromFlagValue = "1"
}
if err := os.WriteFile(cdromFlag, []byte(cdromFlagValue), 0o666); err != nil {
log.Errorf("set cdrom flag failed: %s", err)
rsp.ErrRsp(c, -2, "set cdrom flag failed")
return
}
inquiryVen := "NanoKVM"
inquiryPrd := "USB Mass Storage"
inquiryVer := 0x0520
if req.Cdrom {
inquiryPrd = "USB CD/DVD-ROM"
}
// the image name is what the host shows for the device and for the medium,
// the SCSI inquiry is the medium and the USB product string the device itself
productName := usbProductName
if req.File != "" {
if name := imageName(req.File); name != "" {
inquiryPrd = cutName(name, inquiryProductSize)
productName = cutName(name, usbProductSize)
}
}
inquiryData := fmt.Sprintf("%-8s%-16s%04x", inquiryVen, inquiryPrd, inquiryVer)
if err := os.WriteFile(inquiryString, []byte(inquiryData), 0o666); err != nil {
log.Errorf("set inquiry %s failed: %s", inquiryData, err)
rsp.ErrRsp(c, -2, "set inquiry failed")
return
}
if err := os.WriteFile(usbProduct, []byte(productName), 0o666); err != nil {
log.Errorf("set usb product %s failed: %s", productName, err)
}
// mount
image := req.File
if image == "" {
image = imageNone
}
if err := os.WriteFile(mountDevice, []byte(image), 0o666); err != nil {
log.Errorf("mount file %s failed: %s", image, err)
rsp.ErrRsp(c, -2, "mount image failed")
return
}
// An image is served read-only, so the KVM writes /data itself again. The
// host can not write the image either way.
if req.File != "" {
if err := SetDataWritable(true); err != nil {
log.Warnf("make %s writable failed: %s", imageDirectory, err)
}
}
h := hid.GetHid()
h.Lock()
h.CloseNoLock()
defer func() {
h.OpenNoLock()
h.Unlock()
}()
// reset usb
commands := []string{
"echo > /sys/kernel/config/usb_gadget/g0/UDC",
"ls /sys/class/udc/ | cat > /sys/kernel/config/usb_gadget/g0/UDC",
}
for _, command := range commands {
err := exec.Command("sh", "-c", command).Run()
rel, err := legacyImageRel(req.File)
if err != nil {
rsp.ErrRsp(c, -2, "execute command failed")
respondError(c, err)
return
}
time.Sleep(100 * time.Millisecond)
backing, err = utils.ResolveMediaID(rel)
if err != nil {
respondError(c, err)
return
}
}
if req.Cdrom {
kind, err := diskfs.Detect(backing)
if err != nil || !kind.Optical() {
respondError(c, fmt.Errorf("%w: %s", errNotOptical, req.File))
return
}
}
// An image is always served read-only, the raw partition is not.
ro := req.Cdrom || req.File != ""
if err := s.attachMedium(backing, req.Cdrom, ro, false); err != nil {
respondError(c, err)
return
}
rsp.OkRsp(c)
@@ -196,132 +88,37 @@ func (s *Service) MountImage(c *gin.Context) {
func (s *Service) GetMountedImage(c *gin.Context) {
var rsp proto.Response
mode, err := hid.GetMode()
status, err := readDiskStatus()
if err != nil {
rsp.ErrRsp(c, -2, "get HID mode failed")
rsp.ErrRsp(c, codeOperationFailed, "read failed")
return
}
if mode == hid.ModeHidOnly {
rsp.OkRspWithData(c, &proto.GetMountedImageRsp{
File: "",
})
return
// The data partition is reported as "nothing mounted", the way this
// endpoint has always answered.
file := ""
if status.State == diskStateImage {
file = status.Backing
}
image, err := MountedImage()
if err != nil {
rsp.ErrRsp(c, -2, "read failed")
return
}
data := &proto.GetMountedImageRsp{
File: image,
}
rsp.OkRspWithData(c, data)
}
// MountedImage returns the mounted image file, empty if no image is mounted
func MountedImage() (string, error) {
content, err := os.ReadFile(mountDevice)
if err != nil {
return "", err
}
image := strings.ReplaceAll(string(content), "\n", "")
if image == imageNone {
image = ""
}
return image, nil
}
// imageName returns the file name of an image without the extension
func imageName(file string) string {
return strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))
}
// cutName cuts a name to size bytes without splitting a character
func cutName(name string, size int) string {
if len(name) <= size {
return name
}
name = name[:size]
for !utf8.ValidString(name) {
name = name[:len(name)-1]
}
return name
}
// SetDataWritable remounts /data read-write or read-only. Only one side may
// write the partition at a time: while the virtual disk shares it with the host
// as a writable disk, /data is read-only here, and it becomes writable again
// once the share is released or an image is served read-only instead.
func SetDataWritable(writable bool) error {
var stat unix.Statfs_t
if err := unix.Statfs(imageDirectory, &stat); err != nil {
return err
}
if (stat.Flags&unix.ST_RDONLY == 0) == writable {
return nil
}
option := "rw"
if !writable {
option = "ro"
}
if err := exec.Command("mount", "-o", "remount,"+option, imageDirectory).Run(); err != nil {
return err
}
// never hand a writable /data to the host
var check unix.Statfs_t
if err := unix.Statfs(imageDirectory, &check); err != nil {
return err
}
if (check.Flags&unix.ST_RDONLY == 0) != writable {
return errors.New(imageDirectory + " mount did not change")
}
return nil
}
// DataDiskAttached reports whether the mass storage device serves the whole /data partition to the host
func DataDiskAttached() bool {
content, err := os.ReadFile(mountDevice)
if err != nil {
return false
}
return strings.ReplaceAll(string(content), "\n", "") == imageNone
rsp.OkRspWithData(c, &proto.GetMountedImageRsp{File: file})
}
func (s *Service) GetCdRom(c *gin.Context) {
var rsp proto.Response
content, err := os.ReadFile(cdromFlag)
cdrom, err := readLunBool(lunCDRomFlag)
if err != nil {
rsp.ErrRsp(c, -1, "read failed")
rsp.ErrRsp(c, codeOperationFailed, "read failed")
return
}
flag := strings.ReplaceAll(string(content), "\n", "")
flatInt, err := strconv.ParseInt(flag, 10, 64)
if err != nil {
rsp.ErrRsp(c, -2, "parse failed")
return
flag := int64(0)
if cdrom {
flag = 1
}
data := &proto.GetCdRomRsp{
Cdrom: flatInt,
}
rsp.OkRspWithData(c, data)
rsp.OkRspWithData(c, &proto.GetCdRomRsp{Cdrom: flag})
}
func (s *Service) DeleteImage(c *gin.Context) {
@@ -329,25 +126,45 @@ func (s *Service) DeleteImage(c *gin.Context) {
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
return
}
filename := strings.ToLower(req.File)
validPrefix := strings.HasPrefix(filename, imageDirectory)
validSuffix := strings.HasSuffix(filename, ".iso") || strings.HasSuffix(filename, ".img")
if !validPrefix || !validSuffix {
rsp.ErrRsp(c, -2, "invalid arguments")
rel, err := legacyImageRel(req.File)
if err != nil {
respondError(c, err)
return
}
if err := os.Remove(req.File); err != nil {
rsp.ErrRsp(c, -3, "remove file failed")
log.Errorf("failed to remove file %s: %s", req.File, err)
target, err := utils.ResolveMediaID(rel)
if err != nil {
respondError(c, err)
return
}
if err := deleteMedium(target); err != nil {
log.Errorf("failed to remove file %s: %s", target, err)
respondError(c, err)
return
}
rsp.OkRsp(c)
log.Debugf("delete image %s success", req.File)
}
// legacyImageRel turns the absolute path this API has always used into a
// medium id. The old validation was a string prefix check, which also accepted
// "/dataevil.img" and "/data/../../etc/passwd.iso"; resolving the path
// properly closes both.
func legacyImageRel(file string) (string, error) {
if !filepath.IsAbs(file) {
return "", fmt.Errorf("%w: %s is not an absolute path", utils.ErrInvalidMediaID, file)
}
rel, err := filepath.Rel(mediaDirectory, filepath.Clean(file))
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("%w: %s is outside %s", utils.ErrInvalidMediaID, file, mediaDirectory)
}
return rel, nil
}

View File

@@ -0,0 +1,151 @@
package storage
import (
"context"
"errors"
"sync/atomic"
"time"
"NanoKVM-Server/utils"
)
type jobState string
const (
jobStateIdle jobState = "idle"
jobStateInProgress jobState = "in_progress"
jobStateSuccess jobState = "success"
jobStateFailed jobState = "failed"
)
// jobProgressInterval matches the ticker the downloader reports with, so the
// web UI polls every transfer the same way.
const jobProgressInterval = 2500 * time.Millisecond
// maxQueuedJobs bounds the queue: the transfers hold their HTTP connections
// open while they wait, so an unbounded queue would let one client hoard them.
const maxQueuedJobs = 8
var errTooManyJobs = errors.New("too many transfers are already queued")
// activeJobs counts the file manager transfers holding a job slot, across
// every Service, so code outside the handlers can tell whether a transfer
// temporary may still be written.
var activeJobs atomic.Int32
// TransferInProgress reports whether any transfer that writes a temporary
// file can be running: a file manager upload holds a job slot, the image
// downloader and the updater hold the transfer lock file. A temporary found
// while this is false was left behind by an interrupted transfer.
func TransferInProgress() bool {
return activeJobs.Load() > 0 || pathExists(utils.TransferSentinelPath)
}
// activeJobTarget returns the path the running upload writes, if any.
func (s *Service) activeJobTarget() (string, bool) {
s.jobMutex.Lock()
defer s.jobMutex.Unlock()
return s.jobName, s.jobDone != nil
}
// beginJob claims the single media job slot, queueing behind whatever is
// running instead of refusing: the user asked for a file to be written, and
// waiting their turn is what they expect. The queue is first come, first
// served, and a request whose client goes away leaves it.
//
// The transfer lock file that coordinates with the image downloader and the
// application updater is deliberately not taken here. Writing into an image
// only touches blocks that image already owns, so it neither races the
// downloader nor holds it up.
func (s *Service) beginJob(ctx context.Context, name string) (chan struct{}, error) {
for {
s.jobMutex.Lock()
if s.jobDone == nil {
done := make(chan struct{})
s.jobDone = done
s.jobState = jobStateInProgress
s.jobName = name
s.jobLoaded = 0
s.jobTotal = 0
s.jobMessage = ""
activeJobs.Add(1)
s.jobMutex.Unlock()
return done, nil
}
if len(s.jobWaiters) >= maxQueuedJobs {
s.jobMutex.Unlock()
return nil, errTooManyJobs
}
waiter := make(chan struct{})
s.jobWaiters = append(s.jobWaiters, waiter)
s.jobMutex.Unlock()
select {
case <-waiter:
// Our turn; the loop tries to claim the slot.
case <-ctx.Done():
s.abandonWaiter(waiter)
return nil, ctx.Err()
}
}
}
// abandonWaiter drops a waiter whose client went away. A waiter that is still
// queued only has to leave the queue; one that was already handed the turn
// (finishJob closed its channel) has to pass it on, or the transfers waiting
// behind it would sit there until their own clients gave up too.
func (s *Service) abandonWaiter(waiter chan struct{}) {
s.jobMutex.Lock()
defer s.jobMutex.Unlock()
for i, queued := range s.jobWaiters {
if queued == waiter {
s.jobWaiters = append(s.jobWaiters[:i], s.jobWaiters[i+1:]...)
return
}
}
if s.jobDone == nil && len(s.jobWaiters) > 0 {
close(s.jobWaiters[0])
s.jobWaiters = s.jobWaiters[1:]
}
}
func (s *Service) setJobProgress(done chan struct{}, loaded, total int64) {
s.jobMutex.Lock()
defer s.jobMutex.Unlock()
if s.jobDone == done {
s.jobLoaded = loaded
s.jobTotal = total
}
}
func (s *Service) finishJob(done chan struct{}, state jobState, message string) {
s.jobMutex.Lock()
if s.jobDone != done {
s.jobMutex.Unlock()
return
}
s.jobDone = nil
activeJobs.Add(-1)
s.jobState = state
s.jobLoaded = 0
s.jobTotal = 0
s.jobMessage = message
// The slot goes to whoever queued first.
if len(s.jobWaiters) > 0 {
close(s.jobWaiters[0])
s.jobWaiters = s.jobWaiters[1:]
}
s.jobMutex.Unlock()
close(done)
}

View File

@@ -0,0 +1,880 @@
package storage
import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"NanoKVM-Server/proto"
"NanoKVM-Server/service/storage/diskfs"
"NanoKVM-Server/utils"
)
// Error codes the web UI reacts to. The response envelope is shared with every
// other endpoint, so these stay inside the media API's own range: the UI needs
// to tell "the target machine is still holding the medium" apart from "you
// have to enable the virtual disk first", and neither apart from a plain
// failure.
const (
codeInvalidArgument = -1
codeOperationFailed = -2
codeStorageDisabled = -10
codeMediumLocked = -11
codeMediumBusy = -12
codeNotBrowsable = -13
codeInsufficientSpace = -14
codeNotOptical = -15
codeAlreadyExists = -16
)
// partitionDevice is the whole data partition. It is what the emulated disk
// exposes by default, and while it is attached every medium is at the mercy of
// the target machine, because every medium lives on it.
const partitionDevice = "/dev/mmcblk0p3"
// mediaDirectory holds the media. It is the same directory the virtual disk
// hands to the target machine, so nothing secret belongs in it.
const mediaDirectory = utils.MediaDirectory
// mediumOperationMu serializes the two things that must not interleave: the
// file manager opening a medium and the gadget serving one.
var mediumOperationMu sync.Mutex
// partitionWriteRefs counts long-running writes that land directly on the
// data partition. The counter alone is atomic because a handle releases it
// after openMedium has returned; taking the reference still happens under
// mediumOperationMu, together with the gadget-state check, so a mount cannot
// slip between the two.
var partitionWriteRefs atomic.Int64
// LockVirtualDiskTransition serializes a gadget transition with the media API.
// It applies to disk and network changes because both restart the gadget and
// can make the boot script expose the raw partition. The returned function
// releases the lock and must be deferred by the caller. A transition is refused
// while the file manager holds an image: exposing the raw partition would
// otherwise let the target machine overwrite the image underneath its loop
// device.
func LockVirtualDiskTransition() (func(), error) {
mediumOperationMu.Lock()
if err := ensureNoOpenImages(); err != nil {
mediumOperationMu.Unlock()
return nil, err
}
if partitionWriteRefs.Load() > 0 {
mediumOperationMu.Unlock()
return nil, errMediumBusy
}
return mediumOperationMu.Unlock, nil
}
func ensureNoOpenImages() error {
if len(diskfs.MountedImages()) > 0 {
return errMediumBusy
}
return nil
}
var (
errStorageDisabled = errors.New("the virtual disk is disabled on this device")
errPartitionOwned = errors.New("the data partition is attached to the target machine")
errMediumAttached = errors.New("the medium is attached to the target machine")
errMediumBusy = errors.New("the medium is in use by the file manager")
errNoSpace = errors.New("not enough space on the data partition")
errNotOptical = errors.New("a CD-ROM can only carry an ISO 9660 or UDF image")
errNotAMedium = errors.New("the data partition has no file manager of its own")
)
func (s *Service) GetMedia(c *gin.Context) {
var rsp proto.Response
media, err := listMedia()
if err != nil {
respondError(c, err)
return
}
caps := diskfs.Probe()
rsp.OkRspWithData(c, &proto.GetMediaRsp{
Media: media,
Caps: proto.MediaCaps{Loop: caps.Loop},
})
log.Debugf("get media success, total %d", len(media))
}
func (s *Service) GetMediaState(c *gin.Context) {
var rsp proto.Response
// The UI polls this, so a /data left read-only by a failed release is put
// back here rather than waiting for the user to reboot.
if err := healDataPartition(); err != nil {
logHealFailure(err)
}
status, err := readDiskStatus()
if err != nil {
respondError(c, err)
return
}
// With the virtual disk off the LUN may not exist at all, and that state
// is exactly what the web UI needs to see to offer turning it on.
cdrom := false
if status.State != diskStateUnsupported {
cdrom, err = readLunBool(lunCDRomFlag)
if err != nil && !errors.Is(err, os.ErrNotExist) {
respondError(c, err)
return
}
}
state := &proto.MediaStateRsp{
State: string(status.State),
HidOnly: status.HidOnly,
Open: openMediaIDs(),
Cdrom: cdrom,
LocalWritable: DataWritable(),
}
switch status.State {
case diskStateImage:
state.Type = proto.MediaTypeImage
state.Rel = mediaRel(status.Backing)
case diskStatePartition:
state.Type = proto.MediaTypePartition
}
rsp.OkRspWithData(c, state)
}
func (s *Service) GetMediaStatus(c *gin.Context) {
var rsp proto.Response
s.jobMutex.Lock()
data := &proto.MediaStatusRsp{
Status: string(s.jobState),
Name: s.jobName,
Percentage: utils.FormatPercentage(s.jobLoaded, s.jobTotal),
Loaded: s.jobLoaded,
Total: s.jobTotal,
Message: s.jobMessage,
}
s.jobMutex.Unlock()
rsp.OkRspWithData(c, data)
}
func (s *Service) DeleteMedia(c *gin.Context) {
var req proto.MediaRefReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
return
}
if req.Type == proto.MediaTypePartition {
rsp.ErrRsp(c, codeInvalidArgument, "the data partition cannot be deleted")
return
}
backing, err := utils.ResolveMediaID(req.Rel)
if err != nil {
respondError(c, err)
return
}
if err := deleteMedium(backing); err != nil {
respondError(c, err)
return
}
rsp.OkRsp(c)
log.Debugf("delete medium %s success", req.Rel)
}
// deleteMedium keeps the state checks and the unlink in the same critical
// section as gadget attachment. Otherwise a concurrent mount can attach the
// file after the check and leave the gadget reading a deleted inode.
func deleteMedium(backing string) error {
mediumOperationMu.Lock()
defer mediumOperationMu.Unlock()
status, err := readDiskStatus()
if err != nil {
return err
}
if status.State == diskStateImage && status.Backing == backing {
return errMediumAttached
}
if status.State == diskStatePartition {
return errPartitionOwned
}
if diskfs.IsMounted(backing) {
return errMediumBusy
}
if err := os.Remove(backing); err != nil {
return fmt.Errorf("remove %s: %w", backing, err)
}
return nil
}
func (s *Service) MountMedia(c *gin.Context) {
var req proto.MountMediaReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
return
}
backing, err := resolveBacking(req.Type, req.Rel)
if err != nil {
respondError(c, err)
return
}
status, err := readDiskStatus()
if err != nil {
respondError(c, err)
return
}
if status.State == diskStateUnsupported {
respondError(c, errStorageDisabled)
return
}
if diskfs.IsMounted(backing) {
respondError(c, errMediumBusy)
return
}
// A CD-ROM is an optical disc, so its medium has to hold a disc
// filesystem (ISO 9660 or UDF): the gadget then reports 2048 byte sectors,
// and a partition or any other filesystem read that way is what the target
// machine reports as a corrupt disc.
if req.Cdrom {
kind, err := diskfs.Detect(backing)
if err != nil || !kind.Optical() {
respondError(c, fmt.Errorf("%w: %s", errNotOptical, req.Rel))
return
}
}
// An image is always served read-only: the target machine must not write the
// file this side browses, and a read-only medium is what lets this side keep
// writing /data while it is attached.
ro := req.Type != proto.MediaTypePartition || req.Cdrom
if err := s.attachMedium(backing, req.Cdrom, ro, req.Force); err != nil {
respondError(c, err)
return
}
rsp.OkRsp(c)
log.Debugf("mount medium %s (%s) success, cdrom=%v ro=%v", backing, req.Type, req.Cdrom, ro)
}
func (s *Service) EjectMedia(c *gin.Context) {
var rsp proto.Response
// The body is optional: without it this is an ordinary eject, which fails
// when the target machine holds the medium, and the caller retries with
// force after asking the user.
var req proto.EjectMediaReq
_ = proto.ParseFormRequest(c, &req)
if err := s.detachMedium(req.Force); err != nil {
respondError(c, err)
return
}
rsp.OkRsp(c)
log.Debugf("eject medium success")
}
// attachMedium is the whole mount path: refuse a relabeling that changes
// nothing, then run the kernel's required sequence with the HID devices closed.
func (s *Service) attachMedium(backing string, cdrom, ro, force bool) error {
// Mounting here and serving the medium to the target machine are two sides
// of one decision: without the lock, an upload into an image can be opening
// it read-write while the gadget starts serving the same file.
mediumOperationMu.Lock()
defer mediumOperationMu.Unlock()
if backing == partitionDevice {
// Every image lives on the partition. Handing that partition to the
// target while any image has a loop mount would give both sides the
// same blocks, even though the partition itself is not in mounts. A
// direct partition write has no loop mount to check, so it carries an
// explicit reference instead.
if err := ensureNoOpenImages(); err != nil {
return err
}
if partitionWriteRefs.Load() > 0 {
return errMediumBusy
}
} else if diskfs.IsMounted(backing) {
return errMediumBusy
}
status, err := readDiskStatus()
if err != nil {
return err
}
// Attaching what the target machine already has is a no-op: running the
// sequence again would remove the medium and re-enumerate the gadget under
// a machine that is using it - and fail with EBUSY if it holds the disk.
if status.Backing != "" && status.Backing == backing {
config, err := readLunConfig()
if err != nil {
return err
}
if config.cdrom == cdrom && config.ro == ro {
return nil
}
}
err = withHIDLock(func() error {
err := attach(backing, cdrom, ro)
if errors.Is(err, ErrMediumLocked) && force {
// The target machine holds the medium; re-enumerating the gadget
// is the only way to take it back without its cooperation.
if err := unbindUDC(); err != nil {
return err
}
err = attach(backing, cdrom, ro)
}
return err
})
if err != nil {
// attach may have changed /data before a later step failed. Base the
// rollback on the state that was actually left behind, not on the
// state in which the transition started.
if restoreErr := restoreDataAfterFailedAttach(); restoreErr != nil {
return errors.Join(err, restoreErr)
}
}
return err
}
func restoreDataAfterFailedAttach() error {
status, err := readDiskStatus()
if err != nil {
return fmt.Errorf("read medium state after failed mount: %w", err)
}
if status.State == diskStatePartition {
return nil
}
if err := SetDataWritable(true); err != nil {
return fmt.Errorf("restore %s writable after failed mount: %w", mediaDirectory, err)
}
return nil
}
func (s *Service) detachMedium(force bool) error {
mediumOperationMu.Lock()
defer mediumOperationMu.Unlock()
status, err := readDiskStatus()
if err != nil {
return err
}
if status.State == diskStateUnsupported {
return errStorageDisabled
}
if status.State == diskStateNone {
// Nothing is attached, so there is nothing to release and nothing to
// re-enumerate.
return nil
}
release := func() error {
if err := eject(); err != nil {
return err
}
// Nothing is served any more, so the data partition belongs to this
// side again.
releaseDataPartition()
return rebindUDC()
}
err = withHIDLock(release)
if errors.Is(err, ErrMediumLocked) && force {
err = withHIDLock(func() error {
if err := unbindUDC(); err != nil {
return err
}
// The lock went away with the re-enumeration, so the medium can be
// detached now. The gadget is bound again either way: leaving it
// unbound would cost the target machine its keyboard, mouse and
// disk until something else rebinds it.
detachErr := eject()
releaseDataPartition()
bindErr := rebindUDC()
if detachErr != nil {
return detachErr
}
return bindErr
})
}
return err
}
func (s *Service) DownloadMedia(c *gin.Context) {
var rsp proto.Response
rel := c.Query("rel")
if rel == "" || c.Query("type") == proto.MediaTypePartition {
rsp.ErrRsp(c, codeInvalidArgument, "a disk image is required")
return
}
backing, err := utils.ResolveMediaID(rel)
if err != nil {
respondError(c, err)
return
}
status, err := readDiskStatus()
if err != nil {
respondError(c, err)
return
}
// Downloading is reading, and a read-only data partition is still readable:
// the target machine owning the partition does not stop this side from
// streaming an image file out of it. Its contents may change while it is
// being served, which is the same caveat the file browser has.
if status.State == diskStateImage && status.Backing == backing {
respondError(c, errMediumAttached)
return
}
// A file manager write may be in flight; streaming the image now would
// hand out a file that changes underneath the download. The pin refuses a
// writable mount for the whole stream, so the check cannot go stale between
// here and the open the way a one-shot IsMounted lookup did.
releasePin, err := diskfs.AcquireReadPin(backing)
if err != nil {
respondError(c, err)
return
}
defer releasePin()
file, err := os.Open(backing)
if err != nil {
respondError(c, err)
return
}
defer func() {
_ = file.Close()
}()
info, err := file.Stat()
if err != nil {
respondError(c, err)
return
}
c.Header("Content-Disposition", attachmentHeader(filepath.Base(backing)))
c.Header("Content-Type", "application/octet-stream")
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), file)
}
// listMedia returns every image in the media directory plus the data partition
// itself, which stays listed because exposing it over the emulated disk is the
// documented way to drop an image onto the device.
func listMedia() ([]proto.MediaItem, error) {
// Sizes and names under /data can be written by the target machine at any
// time; make the walk see them.
invalidatePartitionCache()
status, err := readDiskStatus()
if err != nil {
return nil, err
}
images, err := findImages()
if err != nil {
return nil, err
}
media := make([]proto.MediaItem, 0, len(images)+1)
for _, path := range images {
info, err := os.Stat(path)
if err != nil {
continue
}
kind, _ := diskfs.Detect(path)
media = append(media, proto.MediaItem{
Type: proto.MediaTypeImage,
Rel: mediaRel(path),
Name: filepath.Base(path),
Size: uint64(info.Size()),
FS: string(kind),
Browsable: kind.Browsable(),
Attached: status.Backing == path,
Modified: info.ModTime(),
})
}
// The partition is what the emulated disk exposes by default, so it is the
// medium a user most often has something to fetch from.
media = append(media, proto.MediaItem{
Type: proto.MediaTypePartition,
Name: "Shared folder",
FS: "exfat",
Browsable: true,
Attached: status.State == diskStatePartition,
})
return media, nil
}
// findImages lists what the web UI offers to mount: every .iso and .img on the
// volume, wherever it was put - uploaded here, copied over by the target
// machine, or left by an older version of this feature.
func findImages() ([]string, error) {
var images []string
err := filepath.Walk(mediaDirectory, func(path string, info os.FileInfo, err error) error {
if err != nil {
// A device whose data partition was never created has no media,
// which is not worth failing the whole listing over.
if os.IsNotExist(err) {
return nil
}
return err
}
if !info.IsDir() && utils.IsMediaFile(info.Name()) {
images = append(images, path)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("list media: %w", err)
}
sort.Strings(images)
return images, nil
}
// resolveBacking maps a request's medium id to the file the gadget would use.
func resolveBacking(mediaType, rel string) (string, error) {
if mediaType == proto.MediaTypePartition {
return partitionDevice, nil
}
return utils.ResolveMediaID(rel)
}
// mediaRel is the inverse of ResolveMediaID, for reporting a backing file back
// to the web UI.
func mediaRel(backing string) string {
return strings.TrimPrefix(backing, mediaDirectory+"/")
}
func openMediaIDs() []string {
mounted := diskfs.MountedImages()
ids := make([]string, 0, len(mounted))
for _, path := range mounted {
ids = append(ids, mediaRel(path))
}
return ids
}
// InstallImage atomically replaces destPath with a downloaded or uploaded
// image. The same lock used by gadget and file-manager operations covers both
// the checks and the rename, so the destination cannot become attached or
// loop-mounted in between.
func InstallImage(tempPath, destPath string) error {
mediumOperationMu.Lock()
defer mediumOperationMu.Unlock()
canonicalDest, err := utils.CanonicalMediaPath(destPath)
if err != nil {
return err
}
destPath = canonicalDest
status, err := readDiskStatus()
if err != nil {
return err
}
return installImage(tempPath, destPath, status, diskfs.IsMounted(destPath))
}
func installImage(tempPath, destPath string, status diskStatus, mounted bool) error {
if status.State == diskStatePartition {
return ErrDataPartitionInUse
}
if status.State == diskStateImage && status.Backing == destPath {
return fmt.Errorf("%s is attached to the target machine; eject it there first", filepath.Base(destPath))
}
if mounted {
return fmt.Errorf("%w: %s", errMediumBusy, filepath.Base(destPath))
}
if err := os.Rename(tempPath, destPath); err != nil {
return fmt.Errorf("install image %s: %w", filepath.Base(destPath), err)
}
return nil
}
// openMedium returns a handle for a medium that is safe to touch. Everything
// that reads or writes a medium's filesystem goes through here, so the two
// ways to corrupt an image - writing it while the target machine owns it, and
// writing it while the whole data partition is attached - are refused in one
// place.
func openMedium(mediaType, rel string, writable bool) (diskfs.Image, error) {
// The check and the mount are one step: the gadget must not start serving a
// medium between the two, or this side ends up writing a filesystem the
// target machine is reading.
mediumOperationMu.Lock()
defer mediumOperationMu.Unlock()
return openMediumLocked(mediaType, rel, writable)
}
// openMediumLocked is openMedium for a caller that already holds
// mediumOperationMu. Keeping the lock until a subsequent operation finishes
// is necessary when a check alone does not protect that operation, as with a
// file delete through the shared partition.
func openMediumLocked(mediaType, rel string, writable bool) (diskfs.Image, error) {
status, err := readDiskStatus()
if err != nil {
return nil, err
}
// A disabled virtual disk (diskStateUnsupported) is not refused here: with
// nothing shared, the data partition is this side's alone and can be
// browsed as usual, and an image needs no gadget to be mounted here.
//
// While the data partition is attached, the target machine owns every
// medium - they all live on it - so nothing from here may write into one:
// the two sides cache the exFAT allocation tables independently, and a
// write from this side can hand the target machine clusters it is already
// using. Reading is fine and needs no such care.
if writable && status.State == diskStatePartition {
return nil, errPartitionOwned
}
// Nothing owns the partition, so a read-only /data here is a leftover the
// mount did not clear. Writing through it would fail with EROFS.
if writable {
if err := SetDataWritable(true); err != nil {
return nil, fmt.Errorf("make %s writable: %w", mediaDirectory, err)
}
}
if mediaType == proto.MediaTypePartition {
// The partition needs no loop device: the emulated disk already hands
// this very directory to the target machine. A writable handle has to
// outlive this mutex: without the reference, a mount could take the
// partition away after open and interrupt the write it returned for.
image, err := diskfs.OpenDirectory(mediaDirectory)
if err != nil || !writable {
return image, err
}
partitionWriteRefs.Add(1)
return &dataPartitionWriteHandle{Image: image}, nil
}
backing, err := utils.ResolveMediaID(rel)
if err != nil {
return nil, err
}
if status.State == diskStateImage && status.Backing == backing {
return nil, errMediumAttached
}
return diskfs.Mount(backing, writable)
}
// dataPartitionWriteHandle keeps the raw partition marked busy until the
// write that opened it has finished and closed the handle.
type dataPartitionWriteHandle struct {
diskfs.Image
released atomic.Bool
}
func (h *dataPartitionWriteHandle) Close() error {
err := h.Image.Close()
if h.released.CompareAndSwap(false, true) {
partitionWriteRefs.Add(-1)
}
return err
}
// BeginDataPartitionWrite reserves the data partition for a transfer that does
// not go through a medium handle, such as writing a whole image download. The
// reference is taken while the state is checked, then held until release runs;
// handing the raw partition to the target machine is refused in between.
func BeginDataPartitionWrite() (func(), error) {
mediumOperationMu.Lock()
status, err := readDiskStatus()
if err != nil {
mediumOperationMu.Unlock()
return nil, err
}
if status.State == diskStatePartition {
mediumOperationMu.Unlock()
return nil, ErrDataPartitionInUse
}
if err := SetDataWritable(true); err != nil {
mediumOperationMu.Unlock()
return nil, fmt.Errorf("make %s writable: %w", mediaDirectory, err)
}
partitionWriteRefs.Add(1)
mediumOperationMu.Unlock()
var once sync.Once
return func() {
once.Do(func() {
partitionWriteRefs.Add(-1)
})
}, nil
}
// releaseDataPartition hands /data back to this side: it is read-only while the
// emulated disk serves the raw partition, and writable again once no medium or
// a read-only image is attached.
func releaseDataPartition() {
// SetDataWritable drops the target-owned cache before remounting read-write.
if err := SetDataWritable(true); err != nil {
log.Warnf("make %s writable failed: %s", mediaDirectory, err)
}
}
// attachmentHeader builds the Content-Disposition of a download the way
// browsers want it: an ASCII fallback for everything old, plus the real name
// percent-encoded, because a file name is not necessarily ASCII and %q would
// send a Go-quoted string instead of a name.
func attachmentHeader(name string) string {
fallback := strings.Map(func(r rune) rune {
if r < 0x20 || r > 0x7e || r == '"' || r == '\\' {
return '_'
}
return r
}, name)
return fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", fallback, rfc5987Encode(name))
}
// rfc5987Encode percent-encodes every byte that is not an RFC 5987 attr-char.
// url.PathEscape is not that set: it leaves "=", ":", "@" and friends alone,
// which a strict parser reads as the end of the value.
func rfc5987Encode(value string) string {
const hex = "0123456789ABCDEF"
var encoded strings.Builder
for i := 0; i < len(value); i++ {
b := value[i]
if isAttrChar(b) {
encoded.WriteByte(b)
continue
}
encoded.WriteByte('%')
encoded.WriteByte(hex[b>>4])
encoded.WriteByte(hex[b&0x0f])
}
return encoded.String()
}
func isAttrChar(b byte) bool {
switch {
case b >= 'A' && b <= 'Z', b >= 'a' && b <= 'z', b >= '0' && b <= '9':
return true
}
return strings.IndexByte("!#$&+-.^_`|~", b) >= 0
}
// ErrDataPartitionInUse reports a write that would land on the data partition
// while the target machine owns it.
var ErrDataPartitionInUse = errors.New("the data partition is attached to the target machine; eject it first")
// EnsureDataPartitionWritable performs the one-shot form of the partition
// ownership check. Anything that keeps writing after the check has to hold
// BeginDataPartitionWrite instead, or the partition can be mounted in between.
func EnsureDataPartitionWritable() error {
release, err := BeginDataPartitionWrite()
if err != nil {
return err
}
release()
return nil
}
// deleteGuard decides whether removing target, the canonical path of a file
// or directory, would take away a medium that must not vanish: the one the
// target machine is served (backing) or one the file manager keeps mounted. A
// directory delete is recursive, so a medium anywhere below target counts.
func deleteGuard(target, backing string, mounted []string) error {
if backing != "" && pathWithin(backing, target) {
return errMediumAttached
}
for _, path := range mounted {
if pathWithin(path, target) {
return errMediumBusy
}
}
return nil
}
// pathWithin reports whether path is dir or lies below it. Both sides are
// compared upper-cased, the way exFAT matches names, so a spelling that only
// differs in case cannot slip past a check that the canonical lookup missed.
func pathWithin(path, dir string) bool {
path = strings.ToUpper(path)
dir = strings.ToUpper(strings.TrimSuffix(dir, "/"))
return path == dir || strings.HasPrefix(path, dir+"/")
}
// IsAttached reports whether the emulated disk is currently backed by path.
// Callers that replace a file in place have to ask first: the gadget keeps
// using the inode it opened, so renaming over it leaves the target machine
// reading a file the rest of the system can no longer see.
func IsAttached(path string) bool {
if canonical, err := utils.CanonicalMediaPath(path); err == nil {
path = canonical
}
status, err := readDiskStatus()
if err != nil {
return false
}
return status.Backing != "" && status.Backing == path
}
func respondError(c *gin.Context, err error) {
var rsp proto.Response
switch {
case errors.Is(err, utils.ErrInvalidMediaID), errors.Is(err, utils.ErrInvalidMediaPath):
rsp.ErrRsp(c, codeInvalidArgument, err.Error())
case errors.Is(err, errStorageDisabled):
rsp.ErrRsp(c, codeStorageDisabled, err.Error())
case errors.Is(err, ErrMediumLocked):
rsp.ErrRsp(c, codeMediumLocked, err.Error())
case errors.Is(err, errPartitionOwned), errors.Is(err, errMediumAttached),
errors.Is(err, errMediumBusy), errors.Is(err, diskfs.ErrBusy):
rsp.ErrRsp(c, codeMediumBusy, err.Error())
case errors.Is(err, diskfs.ErrUnsupported), errors.Is(err, errNotAMedium):
rsp.ErrRsp(c, codeNotBrowsable, err.Error())
case errors.Is(err, errNoSpace), errors.Is(err, errTooManyJobs):
rsp.ErrRsp(c, codeInsufficientSpace, err.Error())
case errors.Is(err, errNotOptical):
rsp.ErrRsp(c, codeNotOptical, err.Error())
default:
rsp.ErrRsp(c, codeOperationFailed, err.Error())
}
}

View File

@@ -0,0 +1,187 @@
package storage
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestDeleteGuard(t *testing.T) {
const attached = "/data/images/win.iso"
mounted := []string{"/data/share/disk.img"}
tests := []struct {
target string
want error
}{
{"/data/images/win.iso", errMediumAttached},
{"/data/IMAGES/WIN.ISO", errMediumAttached},
{"/data/images", errMediumAttached},
{"/data/IMAGES", errMediumAttached},
{"/data/Images/", errMediumAttached},
{"/data/share/disk.img", errMediumBusy},
{"/data/Share", errMediumBusy},
{"/data/images/win.iso.bak", nil},
{"/data/imagesold", nil},
{"/data/share/other.img", nil},
}
for _, tt := range tests {
if got := deleteGuard(tt.target, attached, mounted); !errors.Is(got, tt.want) || (got == nil) != (tt.want == nil) {
t.Errorf("deleteGuard(%q) = %v, want %v", tt.target, got, tt.want)
}
}
if err := deleteGuard("/data/images", "", nil); err != nil {
t.Errorf("deleteGuard with nothing attached = %v, want nil", err)
}
// The raw partition is served by device path, which no file under /data
// can match.
if err := deleteGuard("/data/images", partitionDevice, nil); err != nil {
t.Errorf("deleteGuard with the partition attached = %v, want nil", err)
}
}
func TestPathWithinRelative(t *testing.T) {
// The running upload's target is medium relative, like a delete request.
if !pathWithin("dir/sub/file.bin", "dir") || !pathWithin("DIR/file.bin", "dir") {
t.Error("an upload below the directory was not matched")
}
if pathWithin("dir2/file.bin", "dir") {
t.Error("a sibling with a common prefix was matched")
}
}
func TestAbandonWaiterHandsTurnOn(t *testing.T) {
s := &Service{jobState: jobStateIdle}
done, err := s.beginJob(context.Background(), "a")
if err != nil {
t.Fatal(err)
}
first := make(chan struct{})
second := make(chan struct{})
s.jobWaiters = []chan struct{}{first, second}
// The slot goes to the first waiter, whose client is already gone.
s.finishJob(done, jobStateSuccess, "")
assertClosed(t, first, "first waiter")
assertOpen(t, second, "second waiter before the abandon")
s.abandonWaiter(first)
assertClosed(t, second, "second waiter")
if len(s.jobWaiters) != 0 {
t.Errorf("queue holds %d waiters, want 0", len(s.jobWaiters))
}
if activeJobs.Load() != 0 {
t.Errorf("activeJobs = %d, want 0", activeJobs.Load())
}
}
func TestAbandonQueuedWaiterLeavesOthers(t *testing.T) {
s := &Service{jobState: jobStateIdle}
done, err := s.beginJob(context.Background(), "a")
if err != nil {
t.Fatal(err)
}
defer s.finishJob(done, jobStateSuccess, "")
first := make(chan struct{})
second := make(chan struct{})
s.jobWaiters = []chan struct{}{first, second}
s.abandonWaiter(first)
assertOpen(t, second, "second waiter")
if len(s.jobWaiters) != 1 || s.jobWaiters[0] != second {
t.Errorf("queue = %v, want only the second waiter", s.jobWaiters)
}
}
func assertClosed(t *testing.T, ch chan struct{}, what string) {
t.Helper()
select {
case <-ch:
default:
t.Errorf("%s was not woken", what)
}
}
func assertOpen(t *testing.T, ch chan struct{}, what string) {
t.Helper()
select {
case <-ch:
t.Errorf("%s was woken", what)
default:
}
}
func TestDropCachesValues(t *testing.T) {
// The 5.10 sysctl refuses anything outside 1..4.
for _, value := range []string{dropCachesQuiet, dropCachesDentry, dropCachesAllData} {
if len(value) != 1 || value[0] < '1' || value[0] > '4' {
t.Errorf("drop_caches value %q is outside 1..4", value)
}
}
}
func TestRFC5987Encode(t *testing.T) {
tests := map[string]string{
"ubuntu-24.04.iso": "ubuntu-24.04.iso",
"a b=c:d@e;f,g'h": "a%20b%3Dc%3Ad%40e%3Bf%2Cg%27h",
"!#$&+-.^_`|~": "!#$&+-.^_`|~",
"中.txt": "%E4%B8%AD.txt",
"100%\"x\\y/z": "100%25%22x%5Cy%2Fz",
}
for in, want := range tests {
if got := rfc5987Encode(in); got != want {
t.Errorf("rfc5987Encode(%q) = %q, want %q", in, got, want)
}
}
header := attachmentHeader(`中 "a".iso`)
if !strings.HasPrefix(header, `attachment; filename="_ _a_.iso"; filename*=UTF-8''`) {
t.Errorf("attachmentHeader = %q", header)
}
}
func TestHoldsMedia(t *testing.T) {
root := t.TempDir()
mustWrite := func(rel string) {
full := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, nil, 0o644); err != nil {
t.Fatal(err)
}
}
mustWrite("images/win.ISO")
mustWrite("share/notes.txt")
mustWrite("share/deep/disk.img")
mustWrite("docs/readme.txt")
cases := map[string]bool{
"images": true,
"images/win.ISO": true,
"share": true,
"share/notes.txt": false,
"docs": false,
"missing/new.iso": true,
"missing/notes.txt": false,
"share/deep/disk.img": true,
}
for rel, want := range cases {
got, err := holdsMedia(filepath.Join(root, rel))
if err != nil {
t.Fatalf("holdsMedia(%s): %v", rel, err)
}
if got != want {
t.Errorf("holdsMedia(%s) = %v, want %v", rel, got, want)
}
}
}

View File

@@ -1,7 +1,23 @@
package storage
type Service struct{}
import "sync"
// Service backs the virtual media API. The job fields belong to the single
// long running media operation (a file being written into a medium); they
// follow the download service's shape, so the web UI polls both with the same
// vocabulary.
type Service struct {
jobMutex sync.Mutex
jobDone chan struct{}
jobWaiters []chan struct{}
jobState jobState
jobName string
jobLoaded int64
jobTotal int64
jobMessage string
}
func NewService() *Service {
return &Service{}
startPartitionWatcher()
return &Service{jobState: jobStateIdle}
}

View File

@@ -3,23 +3,20 @@ package stream
import (
"NanoKVM-Server/common"
"sync"
"sync/atomic"
"time"
)
type H264Frame struct {
Data []byte
Result int
Duration time.Duration
Timestamp int64
}
type H264Subscription struct {
frames chan H264Frame
done chan struct{}
closed atomic.Bool
once sync.Once
waitingForKeyframe bool
source *H264Source
frames chan H264Frame
done chan struct{}
once sync.Once
}
type H264Source struct {
@@ -38,7 +35,8 @@ func SubscribeH264() *H264Subscription {
func (s *H264Source) subscribe() *H264Subscription {
subscription := &H264Subscription{
frames: make(chan H264Frame, 4),
source: s,
frames: make(chan H264Frame),
done: make(chan struct{}),
}
@@ -58,6 +56,12 @@ func (s *H264Source) subscribe() *H264Subscription {
}
func (s *H264Subscription) Next() (H264Frame, bool) {
select {
case <-s.done:
return H264Frame{}, false
default:
}
select {
case frame := <-s.frames:
return frame, true
@@ -68,9 +72,8 @@ func (s *H264Subscription) Next() (H264Frame, bool) {
func (s *H264Subscription) Close() {
s.once.Do(func() {
s.closed.Store(true)
close(s.done)
defaultH264Source.remove(s)
s.source.remove(s)
})
}
@@ -83,18 +86,19 @@ func (s *H264Source) remove(subscription *H264Subscription) {
func (s *H264Source) run() {
screen := common.GetScreen()
common.CheckScreen()
fps := screen.FPS
ticker := time.NewTicker(time.Second / time.Duration(fps))
defer ticker.Stop()
vision := common.GetKvmVision()
startTime := time.Now()
s.capture(screen, vision.AcquireH264, vision.StopH264)
}
for range ticker.C {
func (s *H264Source) capture(screen *common.Screen, acquire func(uint16, uint16, uint16, uint16) *common.VideoFrame, stop func()) {
for {
subscribers := s.snapshot()
if len(subscribers) == 0 {
s.mutex.Lock()
if len(s.subscribers) == 0 {
if stop != nil {
stop()
}
s.running = false
s.mutex.Unlock()
return
@@ -103,33 +107,21 @@ func (s *H264Source) run() {
continue
}
if screen.FPS != fps && screen.FPS != 0 {
fps = screen.FPS
ticker.Reset(time.Second / time.Duration(fps))
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
if result < 0 {
frame := H264Frame{Result: result}
for _, subscription := range subscribers {
subscription.send(frame)
}
continue
}
if len(data) == 0 {
continue
}
current := acquire(0, 0, screen.BitRate, uint16(screen.FPS))
frame := H264Frame{
Data: data,
Result: result,
Duration: time.Second / time.Duration(fps),
Timestamp: time.Since(startTime).Microseconds(),
Data: current.Data,
Result: current.Result,
Timestamp: current.Timestamp,
}
for _, subscription := range subscribers {
subscription.send(frame)
}
GetFrameRateCounter().Update()
current.Release()
if frame.Result >= 0 && len(frame.Data) > 0 {
GetFrameRateCounter().Update()
} else {
time.Sleep(time.Second / time.Duration(max(screen.FPS, 1)))
}
}
}
@@ -145,41 +137,15 @@ func (s *H264Source) snapshot() []*H264Subscription {
}
func (s *H264Subscription) send(frame H264Frame) bool {
if s.closed.Load() {
select {
case <-s.done:
return false
default:
}
if s.waitingForKeyframe {
if frame.Result != 3 {
return false
}
s.waitingForKeyframe = false
}
select {
case s.frames <- frame:
return true
case <-s.done:
return false
default:
}
for {
select {
case <-s.frames:
case <-s.done:
return false
default:
s.waitingForKeyframe = true
if frame.Result != 3 {
return false
}
s.waitingForKeyframe = false
select {
case s.frames <- frame:
return true
case <-s.done:
return false
}
}
}
}

View File

@@ -4,7 +4,9 @@ import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"NanoKVM-Server/service/vm"
"bytes"
"fmt"
"image/jpeg"
"strconv"
"sync"
"sync/atomic"
@@ -83,32 +85,69 @@ func (s *Streamer) getClients() []*gin.Context {
}
func (s *Streamer) run() {
defer atomic.StoreInt32(&s.running, 0)
screen := common.GetScreen()
common.CheckScreen()
fps := screen.FPS
vision := common.GetKvmVision()
s.streamFrames(screen, vision.AcquireMjpeg, vision.StopMjpeg)
}
ticker := time.NewTicker(time.Second / time.Duration(fps))
defer ticker.Stop()
func (s *Streamer) streamFrames(screen *common.Screen, acquire func(uint16, uint16, uint16, uint16) *common.VideoFrame, stop func()) {
frames := make(chan *common.VideoFrame)
done := make(chan struct{})
captureDone := make(chan struct{})
go func() {
defer close(captureDone)
defer stop()
for {
select {
case <-done:
return
default:
}
frame := acquire(0, 0, screen.Quality, uint16(screen.FPS))
select {
case frames <- frame:
frame.Release()
case <-done:
frame.Release()
return
}
if frame.Result < 0 {
select {
case <-time.After(time.Second / time.Duration(max(screen.FPS, 1))):
case <-done:
return
}
}
}
}()
for range ticker.C {
for {
clients := s.getClients()
if len(clients) == 0 {
log.Debug("mjpeg stream stopped due to no clients")
return
s.mutex.Lock()
if len(s.clients) == 0 {
close(done)
<-captureDone
atomic.StoreInt32(&s.running, 0)
s.mutex.Unlock()
log.Debug("mjpeg stream stopped due to no clients")
return
}
s.mutex.Unlock()
continue
}
data, result := vision.ReadMjpeg(screen.Width, screen.Height, screen.Quality)
frame := <-frames
data, result := frame.Data, frame.Result
stream.UpdateCaptureStatus(stream.CaptureModeMJPEG, result)
if result < 0 || result == 5 || len(data) == 0 {
continue
}
if s.frameCacheEnabled() {
s.setLatestFrame(data, screen.Width, screen.Height)
s.setLatestFrame(data)
}
for _, client := range clients {
@@ -118,16 +157,15 @@ func (s *Streamer) run() {
}
}
if screen.FPS != fps && screen.FPS != 0 {
fps = screen.FPS
ticker.Reset(time.Second / time.Duration(fps))
}
stream.GetFrameRateCounter().Update()
}
}
func (s *Streamer) setLatestFrame(data []byte, width uint16, height uint16) {
func (s *Streamer) setLatestFrame(data []byte) {
size, err := jpeg.DecodeConfig(bytes.NewReader(data))
if err != nil {
return
}
frameCopy := append([]byte(nil), data...)
s.frameMutex.Lock()
@@ -135,8 +173,8 @@ func (s *Streamer) setLatestFrame(data []byte, width uint16, height uint16) {
s.latestFrame = LatestFrame{
Data: frameCopy,
Width: width,
Height: height,
Width: uint16(size.Width),
Height: uint16(size.Height),
CapturedAt: time.Now(),
}
}

View File

@@ -5,24 +5,14 @@ import (
"NanoKVM-Server/service/vm"
"github.com/gorilla/websocket"
"github.com/pion/rtp"
"github.com/pion/rtp/codecs"
"github.com/pion/webrtc/v4/pkg/media"
log "github.com/sirupsen/logrus"
)
func NewWebRTCManager() *WebRTCManager {
m := &WebRTCManager{
clients: make(map[*websocket.Conn]*Client),
videoPacketizer: rtp.NewPacketizer(
1200,
100,
0x1234ABCD,
&codecs.H264Payloader{},
rtp.NewRandomSequencer(),
90000,
),
videoSending: false,
clients: make(map[*websocket.Conn]*Client),
videoPacketizer: newH264Packetizer(),
videoSending: false,
}
m.updateClientSnapshotLocked()
@@ -85,7 +75,7 @@ func (m *WebRTCManager) StartVideoStream() {
m.videoSending = true
m.mutex.Unlock()
go m.sendVideoStream()
go m.writeVideoStream()
log.Debugf("start sending h264 stream")
}
@@ -101,28 +91,21 @@ func (m *WebRTCManager) stopVideoStreamIfIdle() bool {
return true
}
func (m *WebRTCManager) sendVideoStream() {
func (m *WebRTCManager) writeVideoStream() {
subscription := stream.SubscribeH264()
defer subscription.Close()
samples, writerDone := m.startVideoWriter()
for {
frame, ok := subscription.Next()
if !ok {
close(samples)
<-writerDone
return
}
clients := m.getClients()
if len(clients) == 0 {
close(samples)
<-writerDone
if m.stopVideoStreamIfIdle() {
log.Debugf("stop sending h264 stream")
return
}
samples, writerDone = m.startVideoWriter()
continue
}
@@ -131,33 +114,13 @@ func (m *WebRTCManager) sendVideoStream() {
continue
}
sample := media.Sample{
Data: frame.Data,
Duration: frame.Duration,
}
samples <- sample
}
}
func (m *WebRTCManager) startVideoWriter() (chan media.Sample, <-chan struct{}) {
samples := make(chan media.Sample, 1)
done := make(chan struct{})
go func() {
defer close(done)
for sample := range samples {
packets := m.videoPacketizer.Packetize(sample.Data, uint32(sample.Duration.Seconds()*90000))
for _, client := range m.getClients() {
err := client.track.writeVideoPackets(packets)
if err != nil {
log.Errorf("failed to write h264 video to client: %s", err)
m.RemoveClient(client.WsConn())
client.Close()
}
packets := m.videoPacketizer.Packetize(frame.Data, frame.Timestamp)
for _, client := range clients {
if err := client.track.writeVideoPackets(packets); err != nil {
log.Errorf("failed to write h264 video to client: %s", err)
m.RemoveClient(client.WsConn())
client.Close()
}
}
}()
return samples, done
}
}

View File

@@ -0,0 +1,33 @@
package webrtc
import (
"github.com/pion/rtp"
"github.com/pion/rtp/codecs"
)
type h264Packetizer struct {
packetizer rtp.Packetizer
lastTicks uint64
initialized bool
}
func newH264Packetizer() *h264Packetizer {
return &h264Packetizer{
packetizer: rtp.NewPacketizer(1200, 100, 0x1234ABCD,
&codecs.H264Payloader{}, rtp.NewRandomSequencer(), 90000),
}
}
func (p *h264Packetizer) Packetize(data []byte, timestamp int64) []*rtp.Packet {
if len(data) == 0 {
return nil
}
ticks := uint64(timestamp) * 90 / 1000
if p.initialized {
p.packetizer.SkipSamples(uint32(ticks - p.lastTicks))
}
p.lastTicks = ticks
p.initialized = true
return p.packetizer.Packetize(data, 0)
}

View File

@@ -5,14 +5,13 @@ import (
"sync/atomic"
"github.com/gorilla/websocket"
"github.com/pion/rtp"
"github.com/pion/webrtc/v4"
)
type WebRTCManager struct {
clients map[*websocket.Conn]*Client
clientSnapshot atomic.Pointer[[]*Client]
videoPacketizer rtp.Packetizer
videoPacketizer *h264Packetizer
videoSending bool
mutex sync.Mutex
viewerVersion uint64

View File

@@ -13,10 +13,9 @@ import (
)
var screenFileMap = map[string]string{
"type": "/kvmapp/kvm/type",
"fps": "/kvmapp/kvm/fps",
"quality": "/kvmapp/kvm/qlty",
"resolution": "/kvmapp/kvm/res",
"type": "/kvmapp/kvm/type",
"fps": "/kvmapp/kvm/fps",
"quality": "/kvmapp/kvm/qlty",
}
func (s *Service) SetScreen(c *gin.Context) {

View File

@@ -69,36 +69,92 @@ func (s *Service) UpdateVirtualDevice(c *gin.Context) {
var device string
var commands []string
preparePartition := false
// A caller that says which state it wants gets that state; a caller that
// does not is toggling, the way this endpoint has always worked.
switch req.Device {
case "network":
device = virtualNetwork
exist, _ := isDeviceExist(device)
if !exist {
enable := !exist
if req.Enable != nil {
enable = *req.Enable
}
if enable && !exist {
commands = mountNetworkCommands
} else {
} else if !enable && exist {
commands = unmountNetworkCommands
}
if len(commands) > 0 {
// Restarting the gadget for a network change also makes the boot
// script attach whatever /boot/usb.disk0 names. When the virtual
// disk is enabled that is the raw data partition by default, so
// this transition needs the same protection as a disk mount.
diskEnabled, err := isDeviceExist(virtualDisk)
if err != nil {
rsp.ErrRsp(c, -3, "check virtual disk failed")
return
}
preparePartition = diskEnabled
}
case "disk":
device = virtualDisk
if diskAttached() {
commands = unmountDiskCommands
} else {
// the host writes the partition from now on. A mounted image is released by
// the restarted gadget, which attaches the raw partition again.
// The settings switch toggles: it reads "on" only while the raw
// partition is served, so turning it on while an image is mounted
// cancels that mount, which the UI confirms first. An explicit enable
// is a request for the virtual disk to exist at all, so it compares
// against that instead and leaves a served image alone.
enabled, err := isDeviceExist(device)
if err != nil {
rsp.ErrRsp(c, -3, "check virtual disk failed")
return
}
current := diskAttached()
enable := !current
if req.Enable != nil {
current = enabled
enable = *req.Enable
}
// Asking for the state that already holds changes nothing: a caller
// that says "enable" must not switch off a running share.
if enable != current {
if current {
commands = unmountDiskCommands
} else {
// The restarted gadget attaches the raw partition. The actual
// read-only transition happens under the media operation lock
// below, before any script can expose the partition.
preparePartition = true
commands = mountDiskCommands
}
}
default:
rsp.ErrRsp(c, -2, "invalid arguments")
return
}
if len(commands) > 0 && (device == virtualDisk || preparePartition) {
unlock, err := storage.LockVirtualDiskTransition()
if err != nil {
rsp.ErrRsp(c, -3, err.Error())
return
}
defer unlock()
if preparePartition {
// The host writes the partition from now on. A mounted image is
// released by the restarted gadget, which attaches the raw
// partition again. This must happen before the script can expose
// that partition, including when the script is changing networking.
if err := storage.SetDataWritable(false); err != nil {
log.Errorf("make /data read-only failed: %s", err)
rsp.ErrRsp(c, -3, "make /data read-only failed")
return
}
commands = mountDiskCommands
}
default:
rsp.ErrRsp(c, -2, "invalid arguments")
return
}
h := hid.GetHid()
@@ -109,21 +165,30 @@ func (s *Service) UpdateVirtualDevice(c *gin.Context) {
h.Unlock()
}()
var commandErr error
for _, command := range commands {
err := exec.Command("sh", "-c", command).Run()
if err != nil {
rsp.ErrRsp(c, -3, "operation failed")
return
if commandErr = exec.Command("sh", "-c", command).Run(); commandErr != nil {
log.Errorf("run %q failed: %s", command, commandErr)
break
}
}
// the disk is gone from the host, the KVM writes /data itself again
if device == virtualDisk && !diskAttached() {
// Whatever the scripts got to, /data belongs to this side again unless
// the raw partition really is served to the target machine now. That
// covers a failed script, which may have stopped before re-attaching the
// partition it was made read-only for, and a network change, after which
// the restarted gadget may serve the partition or not.
if len(commands) > 0 && (device == virtualDisk || preparePartition) && !diskAttached() {
if err := storage.SetDataWritable(true); err != nil {
log.Errorf("make /data writable failed: %s", err)
}
}
if commandErr != nil {
rsp.ErrRsp(c, -3, "operation failed")
return
}
on, _ := isDeviceExist(device)
rsp.OkRspWithData(c, &proto.UpdateVirtualDeviceRsp{
On: on,

View File

@@ -27,9 +27,11 @@ const (
clientHeartbeatTimeout = 90 * time.Second
)
func NewClient(ws *websocket.Conn) *Client {
func NewClient(ws *websocket.Conn, readOnly bool, username string) *Client {
client := &Client{
ws: ws,
readOnly: readOnly,
username: username,
hid: hid.GetHid(),
manual: inputcontrol.NewManualSession(controlmode.GetManager(), inputcontrol.GetCoordinator()),
keyboard: make(chan hid.QueuedReport, 200),
@@ -85,6 +87,9 @@ func (c *Client) Read() error {
switch data[0] {
case Heartbeat:
case KeyboardEvent:
if c.readOnly {
continue
}
report := data[1:]
if len(report) != 8 {
log.Debugf("invalid manual keyboard report: %v", report)
@@ -92,6 +97,9 @@ func (c *Client) Read() error {
}
c.queueManualReport(c.keyboard, inputcontrol.ManualKeyboard, report, keyboardReportHeld(report), true)
case MouseEvent:
if c.readOnly {
continue
}
report := data[1:]
if len(report) != 4 && len(report) != 6 {
log.Debugf("invalid manual mouse report: %v", report)

View File

@@ -33,6 +33,16 @@ func (m *Manager) RemoveClient(ws *websocket.Conn) {
delete(m.clients, ws)
}
// NotifyUser sends an event to every connection owned by the user, e.g. to
// tell the frontend that account permissions changed.
func (m *Manager) NotifyUser(username string, event string, data string) {
for _, client := range m.GetClients() {
if client.username == username {
_ = client.Write(event, data)
}
}
}
func (m *Manager) GetClients() []*Client {
m.mutex.Lock()
defer m.mutex.Unlock()

View File

@@ -1,6 +1,7 @@
package ws
import (
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"github.com/gin-gonic/gin"
@@ -31,7 +32,16 @@ func (s *Service) Connect(c *gin.Context) {
log.Debug("websocket connected")
client := NewClient(ws)
// View-only sessions keep receiving frames but their keyboard and mouse
// reports are dropped.
readOnly := true
username := ""
if principal, ok := middleware.CurrentPrincipal(c); ok {
readOnly = !principal.Has(authn.PermConsoleControl)
username = principal.Username
}
client := NewClient(ws, readOnly, username)
manager := GetManager()
manager.AddClient(ws, client)

View File

@@ -0,0 +1,47 @@
package ws
import (
"NanoKVM-Server/service/storage"
log "github.com/sirupsen/logrus"
)
// StorageEvent tells the web UI that the shared files view was refreshed: the
// watcher drops the exFAT metadata caches and reports what the target machine
// wrote to the data partition, including the size a transfer has reached. The
// listener simply re-reads what it is showing.
const StorageEvent = "storage"
// storageNotifications carries the refresh notifications to their own
// goroutine. The watcher refreshes once a second while the target machine owns
// the shared files, and a client that stopped reading would otherwise hold it
// up for a whole write deadline on every refresh. One slot is enough: a
// notification that arrives while one is still going out is not worth a second
// write, because the listener reads the current state either way.
var storageNotifications = make(chan struct{}, 1)
func init() {
storage.SubscribePartitionChanges(queueStorageChanged)
go broadcastStorageChanges()
}
func queueStorageChanged() {
select {
case storageNotifications <- struct{}{}:
default:
}
}
func broadcastStorageChanges() {
for range storageNotifications {
broadcastStorageChanged()
}
}
func broadcastStorageChanged() {
for _, client := range GetManager().GetClients() {
if err := client.Write(StorageEvent, "{}"); err != nil {
log.Errorf("failed to send storage change: %s", err)
}
}
}

View File

@@ -17,6 +17,8 @@ type Manager struct {
type Client struct {
ws *websocket.Conn
readOnly bool
username string
hid *hid.Hid
manual *inputcontrol.ManualSession
keyboard chan hid.QueuedReport

233
server/utils/mediapath.go Normal file
View File

@@ -0,0 +1,233 @@
package utils
import (
"errors"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"unicode/utf8"
)
// MediaDirectory is the volume the emulated USB disk exposes: the target
// machine sees it as one removable drive, so nothing secret belongs here.
//
// It is split into two folders so both sides know what they are looking at:
// the shared folder is the drop box for exchanging files, the images folder is
// this device's own library of disk images.
const (
MediaDirectory = "/data"
ShareDirectory = MediaDirectory + "/share"
ImagesDirectory = MediaDirectory + "/images"
)
// maxNameLength is the FAT/exFAT limit in UTF-16 code units; counting runes is
// the closest practical approximation on the Go side.
const maxNameLength = 255
// forbiddenNameChars cannot appear in a name on any platform that mounts
// FAT/exFAT, so the file manager rejects them before the kernel sees them.
const forbiddenNameChars = `<>:"/\|?*`
// reservedNames are the DOS device names Windows still refuses, with or
// without an extension.
var reservedNames = map[string]bool{
"CON": true, "PRN": true, "AUX": true, "NUL": true,
"COM1": true, "COM2": true, "COM3": true, "COM4": true, "COM5": true,
"COM6": true, "COM7": true, "COM8": true, "COM9": true,
"LPT1": true, "LPT2": true, "LPT3": true, "LPT4": true, "LPT5": true,
"LPT6": true, "LPT7": true, "LPT8": true, "LPT9": true,
}
var (
ErrInvalidMediaID = errors.New("invalid medium id")
ErrInvalidMediaPath = errors.New("invalid media path")
)
// IsMediaFile reports whether name looks like a virtual medium. The emulated
// disk only ever exposes disk images, so the file manager refuses to touch
// anything else in MediaDirectory.
func IsMediaFile(name string) bool {
return IsISOFile(name) || strings.HasSuffix(strings.ToLower(filepath.Base(name)), ".img")
}
// IsISOFile reports whether name is an ISO image, which is the one medium
// whose contents can be checked before it is mounted.
func IsISOFile(name string) bool {
return strings.HasSuffix(strings.ToLower(filepath.Base(name)), ".iso")
}
// ResolveMediaID turns a medium id, a path relative to MediaDirectory, into an
// absolute path. Every request naming a file on the device goes through here,
// so escapes (absolute paths, "..", separators where they do not belong) are
// rejected rather than patched up at the call site.
func ResolveMediaID(id string) (string, error) {
id = strings.TrimSpace(id)
if id == "" || strings.ContainsRune(id, 0) {
return "", fmt.Errorf("%w: empty", ErrInvalidMediaID)
}
if strings.HasPrefix(id, "/") || strings.HasPrefix(id, `\`) {
return "", fmt.Errorf("%w: must be relative to %s", ErrInvalidMediaID, MediaDirectory)
}
rel := filepath.FromSlash(id)
if !filepath.IsLocal(rel) {
return "", fmt.Errorf("%w: %s escapes %s", ErrInvalidMediaID, id, MediaDirectory)
}
for _, segment := range strings.Split(filepath.ToSlash(rel), "/") {
if err := validateName(segment); err != nil {
return "", fmt.Errorf("%w: %s", ErrInvalidMediaID, err)
}
}
if !IsMediaFile(rel) {
return "", fmt.Errorf("%w: %s is not a disk image", ErrInvalidMediaID, id)
}
return canonicalMediaPath(MediaDirectory, rel)
}
// CanonicalMediaPath resolves an absolute medium path to the spelling used by
// the directory entries on disk. Like ResolveMediaID, it rejects paths outside
// MediaDirectory and names that are not disk images.
func CanonicalMediaPath(name string) (string, error) {
rel, err := filepath.Rel(MediaDirectory, filepath.Clean(name))
if err != nil || !filepath.IsLocal(rel) {
return "", fmt.Errorf("%w: %s is outside %s", ErrInvalidMediaID, name, MediaDirectory)
}
return ResolveMediaID(filepath.ToSlash(rel))
}
// CanonicalPathInside resolves rel, a path already checked by
// ValidateMediumPath, to the spelling the directory entries under root use.
// Unlike CanonicalMediaPath it accepts directories and arbitrary files: a
// delete has to compare what it removes against the canonical paths of the
// attached and mounted media, and "IMAGES" names the same exFAT directory as
// "images".
func CanonicalPathInside(root, rel string) (string, error) {
if rel == "" || rel == "/" {
return root, nil
}
return canonicalMediaPath(root, filepath.FromSlash(rel))
}
// canonicalMediaPath resolves every existing component to its on-disk name.
// exFAT lookups are case-insensitive, while Go string comparisons are not: an
// all-lower-case request and an upper-case request can name the same medium.
// Prefer an exact match so this also remains deterministic on a case-sensitive
// filesystem, and reject ambiguous folds instead of choosing between files.
// A missing component is kept as requested so callers can still create a new
// image or report the original not-found error.
func canonicalMediaPath(root, rel string) (string, error) {
segments := strings.Split(filepath.ToSlash(rel), "/")
current := root
for i, segment := range segments {
entries, err := os.ReadDir(current)
if err != nil {
if os.IsNotExist(err) {
parts := append([]string{current}, segments[i:]...)
return filepath.Join(parts...), nil
}
return "", fmt.Errorf("resolve medium path %s: %w", filepath.Join(root, rel), err)
}
matches := make([]string, 0, 1)
for _, entry := range entries {
if entry.Name() == segment {
matches = []string{segment}
break
}
if strings.EqualFold(entry.Name(), segment) {
matches = append(matches, entry.Name())
}
}
if len(matches) > 1 {
return "", fmt.Errorf("%w: %s has more than one case-insensitive match", ErrInvalidMediaID, filepath.Join(root, rel))
}
if len(matches) == 0 {
parts := append([]string{current}, segments[i:]...)
return filepath.Join(parts...), nil
}
current = filepath.Join(current, matches[0])
}
return current, nil
}
// ValidateMediumPath normalizes a path inside a mounted medium and returns it
// slash separated and relative to the medium root ("/" means the root itself).
// It takes the same shape the web UI shows, so a leading slash is accepted and
// stripped.
func ValidateMediumPath(p string) (string, error) {
if strings.ContainsRune(p, 0) {
return "", fmt.Errorf("%w: contains NUL", ErrInvalidMediaPath)
}
p = strings.TrimPrefix(strings.TrimSpace(p), "/")
if p == "" {
return "/", nil
}
clean := path.Clean(p)
if clean == "." {
return "/", nil
}
if clean == ".." || strings.HasPrefix(clean, "../") {
return "", fmt.Errorf("%w: %s escapes the medium", ErrInvalidMediaPath, p)
}
for _, segment := range strings.Split(clean, "/") {
if err := validateName(segment); err != nil {
return "", fmt.Errorf("%w: %s", ErrInvalidMediaPath, err)
}
}
return clean, nil
}
// ValidateFileName checks a single file name for FAT/exFAT compatibility. It
// is what an upload's browser-supplied name has to pass before it is written
// into a medium.
func ValidateFileName(name string) error {
return validateName(name)
}
func validateName(name string) error {
if name == "" {
return errors.New("empty name")
}
if name == "." || name == ".." {
return fmt.Errorf("reserved name %q", name)
}
if utf8.RuneCountInString(name) > maxNameLength {
return fmt.Errorf("name longer than %d characters", maxNameLength)
}
if strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
return fmt.Errorf("name %q ends with a dot or space", name)
}
for _, r := range name {
if r < 0x20 || r == 0x7f {
return fmt.Errorf("name %q contains a control character", name)
}
if strings.ContainsRune(forbiddenNameChars, r) {
return fmt.Errorf("name %q contains %q", name, r)
}
}
base := name
if i := strings.IndexByte(base, '.'); i >= 0 {
base = base[:i]
}
if reservedNames[strings.ToUpper(base)] {
return fmt.Errorf("name %q is reserved on Windows", name)
}
return nil
}

View File

@@ -0,0 +1,78 @@
package utils
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestCanonicalPathInside(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "Images", "Sub"))
mustWrite(t, filepath.Join(root, "Images", "Win.iso"))
mustWrite(t, filepath.Join(root, "Images", "notes.txt"))
tests := []struct {
rel string
want string
}{
{"/", root},
{"images", filepath.Join(root, "Images")},
{"IMAGES", filepath.Join(root, "Images")},
{"images/sub", filepath.Join(root, "Images", "Sub")},
{"images/WIN.ISO", filepath.Join(root, "Images", "Win.iso")},
{"IMAGES/NOTES.TXT", filepath.Join(root, "Images", "notes.txt")},
// A missing component keeps the requested spelling from there on.
{"images/new/File.bin", filepath.Join(root, "Images", "new", "File.bin")},
}
for _, tt := range tests {
got, err := CanonicalPathInside(root, tt.rel)
if err != nil {
t.Errorf("CanonicalPathInside(%q) error: %v", tt.rel, err)
continue
}
if got != tt.want {
t.Errorf("CanonicalPathInside(%q) = %q, want %q", tt.rel, got, tt.want)
}
}
}
func TestCanonicalPathInsideAmbiguous(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "a"))
mustMkdir(t, filepath.Join(root, "A"))
if _, err := os.Stat(filepath.Join(root, "a")); err != nil {
t.Skip("case-insensitive filesystem")
}
entries, _ := os.ReadDir(root)
if len(entries) != 2 {
t.Skip("case-insensitive filesystem")
}
// An exact match wins over a fold.
if got, err := CanonicalPathInside(root, "a"); err != nil || got != filepath.Join(root, "a") {
t.Errorf("exact match = %q, %v", got, err)
}
// Two folds and no exact match is refused instead of guessed.
mustMkdir(t, filepath.Join(root, "Bb"))
mustMkdir(t, filepath.Join(root, "bB"))
if _, err := CanonicalPathInside(root, "bb"); !errors.Is(err, ErrInvalidMediaID) {
t.Errorf("ambiguous fold error = %v, want ErrInvalidMediaID", err)
}
}
func mustMkdir(t *testing.T, dir string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
func mustWrite(t *testing.T, file string) {
t.Helper()
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}

91
server/utils/progress.go Normal file
View File

@@ -0,0 +1,91 @@
package utils
import (
"fmt"
"io"
"sync"
"sync/atomic"
"time"
)
// progressInterval matches the polling cadence of the web UI, so a transfer
// never reports a percentage the client has already stopped asking for.
const progressInterval = 2500 * time.Millisecond
// ProgressWriter counts the bytes written through it and reports progress to
// onProgress on a fixed ticker. Transfers that take minutes (image downloads
// and uploads, media creation) use it to feed the status endpoints the web UI
// polls.
type ProgressWriter struct {
writer io.Writer
total atomic.Int64
totalSize int64
ticker *time.Ticker
done chan struct{}
stopOnce sync.Once
onProgress func(loaded, total int64)
}
// NewProgressWriter wraps writer. totalSize is the expected number of bytes;
// when it is unknown (a chunked response, for example) no progress is
// reported. onProgress receives the bytes written so far and the total.
func NewProgressWriter(writer io.Writer, totalSize int64, onProgress func(loaded, total int64)) *ProgressWriter {
pw := &ProgressWriter{
writer: writer,
totalSize: totalSize,
onProgress: onProgress,
}
pw.startTicker()
return pw
}
func (pw *ProgressWriter) startTicker() {
pw.ticker = time.NewTicker(progressInterval)
pw.done = make(chan struct{})
go func() {
for {
select {
case <-pw.done:
return
case <-pw.ticker.C:
pw.updateProgress()
}
}
}()
}
// Stop halts the ticker. It is safe to call more than once and on a nil
// writer, so callers can defer it unconditionally.
func (pw *ProgressWriter) Stop() {
if pw == nil {
return
}
pw.stopOnce.Do(func() {
pw.ticker.Stop()
close(pw.done)
})
}
func (pw *ProgressWriter) updateProgress() {
if pw.totalSize <= 0 || pw.onProgress == nil {
return
}
pw.onProgress(pw.total.Load(), pw.totalSize)
}
// FormatPercentage renders transfer progress the way the status endpoints
// report it: values like "42.13%", and an empty string before the first tick
// or when the total size is unknown.
func FormatPercentage(loaded, total int64) string {
if loaded <= 0 || total <= 0 {
return ""
}
return fmt.Sprintf("%.2f%%", float64(loaded)/float64(total)*100)
}
func (pw *ProgressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p)
pw.total.Add(int64(n))
return n, err
}

25
server/utils/space.go Normal file
View File

@@ -0,0 +1,25 @@
package utils
import (
"fmt"
"math"
"golang.org/x/sys/unix"
)
// Statfs reports the total and available bytes of the filesystem holding path.
// It applies no reserve policy on purpose: each caller decides what it has to
// keep back, so the numbers stay plain.
func Statfs(path string) (total, available uint64, err error) {
var stat unix.Statfs_t
if err := unix.Statfs(path, &stat); err != nil {
return 0, 0, fmt.Errorf("stat filesystem %s: %w", path, err)
}
blockSize := uint64(stat.Bsize)
if blockSize != 0 && (stat.Blocks > math.MaxUint64/blockSize || stat.Bavail > math.MaxUint64/blockSize) {
return 0, 0, fmt.Errorf("filesystem size overflow on %s", path)
}
return stat.Blocks * blockSize, stat.Bavail * blockSize, nil
}

View File

@@ -1,6 +1,7 @@
package utils
// TransferSentinelPath coordinates application uploads and virtual-media
// transfers. /run is already required by the device init scripts and preserves
// TransferSentinelPath coordinates transfers that write new files into the
// data partition: application updates and image downloads. Writing into an
// image a medium already owns does not take it. /run is already required by the device init scripts and preserves
// the previous reboot-clears-stale-state behaviour without using /tmp.
const TransferSentinelPath = "/run/nanokvm-transfer-in-progress"

View File

@@ -0,0 +1,45 @@
// Package webcache labels the static files the server hands out.
//
// The frontend is one entry document plus content-hashed assets. The document
// is what an update replaces, and a browser that keeps it keeps the old app:
// the storage window of a release that updates but "looks unchanged" is that
// cache. Assets carry a hash in their name, so a name that is already cached
// can never change meaning.
package webcache
import (
"os"
"path"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// Headers returns the middleware that sets Cache-Control for the web root.
// Paths it does not recognise are left alone.
func Headers(root string) gin.HandlerFunc {
return func(c *gin.Context) {
// Clean first: "/assets/../x" is not an asset, and it must not be
// resolved outside root either.
requestPath := path.Clean("/" + c.Request.URL.Path)
switch {
case requestPath == "/" || requestPath == "/index.html":
c.Header("Cache-Control", "no-cache")
case strings.HasPrefix(requestPath, "/assets/") && assetExists(root, requestPath):
c.Header("Cache-Control", "public, max-age=31536000, immutable")
}
c.Next()
}
}
// assetExists reports whether the request names a file the build shipped. A
// missing hashed asset is answered with a 404, and marking that immutable would
// keep the URL broken in the browser for a year - which is exactly what a
// rollback to a build whose assets still exist would run into.
func assetExists(root, requestPath string) bool {
relative := strings.TrimPrefix(requestPath, "/")
info, err := os.Stat(filepath.Join(root, filepath.FromSlash(relative)))
return err == nil && info.Mode().IsRegular()
}

View File

@@ -0,0 +1,64 @@
package webcache
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
)
func TestHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "assets"), 0o755); err != nil {
t.Fatal(err)
}
for name, content := range map[string]string{
"index.html": "x",
"assets/index-abc123.js": "x",
"assets/style-abc123.css": "x",
} {
if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(name)), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
tests := []struct {
path string
want string
}{
{"/", "no-cache"},
{"/index.html", "no-cache"},
{"/assets/index-abc123.js", "public, max-age=31536000, immutable"},
{"/assets/style-abc123.css", "public, max-age=31536000, immutable"},
// A missing asset is answered by a 404, and caching that for a year is
// how a rollback to a build whose assets still exist stays broken.
{"/assets/missing.js", ""},
// Classified after cleaning, so a traversal never reaches the assets
// branch or the filesystem.
{"/assets/../index.html", "no-cache"},
// Everything else keeps the server's default: the icon and the API
// must not be pinned to a policy they were never reviewed for.
{"/sipeed.ico", ""},
{"/mockServiceWorker.js", ""},
{"/api/auth/login", ""},
}
for _, tt := range tests {
engine := gin.New()
engine.Use(Headers(root))
engine.NoRoute(func(c *gin.Context) { c.String(http.StatusNotFound, "") })
request := httptest.NewRequest(http.MethodGet, tt.path, nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if got := recorder.Header().Get("Cache-Control"); got != tt.want {
t.Errorf("%s: Cache-Control = %q, want %q", tt.path, got, tt.want)
}
}
}

View File

@@ -1,7 +1,15 @@
# NanoKVM Support Instructions
## Environment Preparation
1. NanoKVM-Lite/Full/PCIe is based on the SG2002 as the main control chip. The projects in the support section are compiled under the [MaixCDK](https://github.com/sipeed/MaixCDK) framework. Before compiling, please ensure that the `MaixCDK` environment is correctly configured. For configuration instructions, click [here](https://github.com/sipeed/MaixCDK/blob/main/docs/doc_zh/README.md).
NanoKVM-Lite/Full/PCIe uses the SG2002. The system daemon and `libkvm.so` wrapper
build with [MaixCDK](https://github.com/sipeed/MaixCDK); `libkvm_mmf.so` builds
separately with CMake using the Sophgo sources in that checkout. Configure and
activate the [MaixCDK environment](https://github.com/sipeed/MaixCDK/blob/main/docs/doc_zh/README.md)
and add the RISC-V musl toolchain to `PATH`. Run the commands below from
`support/sg2002/`. Alternatively, `make vision` at the repository root builds
the video libraries in Docker. `make app` builds the server using the prebuilt
libraries in `server/dl_lib/`.
## kvm_system Compilation Instructions
@@ -11,16 +19,17 @@
2. Modify the paths of `MAIXCDK_PATH` and `NanoKVM_PATH` in `./build`.
3. Execute `./build kvm_system` to compile `kvm_system`.
4. Use `scp ./kvm_system/dist/kvm_system_release/kvm_system root@192.168.x.x:/kvmapp/kvm_system` to copy it to NanoKVM for testing.
5. Use `./build add_to_kvmapp` to place the executable file into the `/kvmapp` installation package.
5. Use `./build kvm_system add_to_kvmapp` to stage the executable in the repository's `kvmapp/` directory.
6. Use `./build kvm_system clean` to clean the compilation of `kvm_system`.
## kvm_vision Compilation Instructions
> `kvm_vision` refers to the image acquisition and encoding subsystem of NanoKVM, compiled with MaixCDK to produce dynamic libraries for Go calls. Use `kvm_vision_test` to compile and test the dynamic library.
> `kvm_vision` builds `libkvm_mmf.so`, then the MaixCDK wrapper `libkvm.so` and the `kvm_vision_test` example.
1. Before compiling, please ensure that the above-mentioned `MaixCDK` environment is correctly configured.
2. Modify the paths of `MAIXCDK_PATH` and `NanoKVM_PATH` in `./build`.
3. Execute `./build kvm_vision` to compile `kvm_vision_test`.
4. You can test the dynamic libraries in `kvm_vision_test/dist/kvm_vision_test_release/dl_lib/`.
5. Use `./build add_to_kvmapp` to place the dynamic libraries into the `/kvmapp` installation package.
6. Use `./build kvm_vision clean` to clean the compilation of `kvm_vision`.
3. Execute `./build kvm_vision` to build both libraries and the example. The library build and install directories are `kvm_vision_test/build/kvm_mmf-build/` and `kvm_vision_test/build/kvm_mmf-install/`.
4. The resulting libraries are in `kvm_vision_test/dist/kvm_vision_test_release/dl_lib/`.
5. Run `./build kvm_vision add_to_kvmapp` to stage them in the repository's `kvmapp/server/dl_lib/`.
6. After changing the video library interfaces, update both prebuilt files `server/dl_lib/libkvm.so` and `server/dl_lib/libkvm_mmf.so` from the same build. Server compilation uses these files directly; see the [server build instructions](../../server/README.md#compile--deploy).
7. Use `./build kvm_vision clean` to remove the build outputs, including both library build and install directories.

View File

@@ -1,7 +1,14 @@
# NanoKVM support 说明
## 环境准备
1. NanoKVM-Lite/Full/PCIe 以SG2002为主控芯片,support 部分的工程在[MaixCDK](https://github.com/sipeed/MaixCDK) 框架下编译。编译前,请保证`MaixCDK`环境已正确配置,配置教程点击[这里](https://github.com/sipeed/MaixCDK/blob/main/docs/doc_zh/README.md)
NanoKVM-Lite/Full/PCIe 以 SG2002 为主控芯片。系统程序和 `libkvm.so` 包装层借助
[MaixCDK](https://github.com/sipeed/MaixCDK) 编译,`libkvm_mmf.so` 使用该目录中的
Sophgo 源码通过 CMake 独立编译。请先配置并激活
[MaixCDK 环境](https://github.com/sipeed/MaixCDK/blob/main/docs/doc_zh/README.md),
将 RISC-V musl 工具链加入 `PATH`。以下命令在 `support/sg2002/` 下执行。
也可以在仓库根目录执行 `make vision`,通过 Docker 构建视频库。
`make app` 使用 `server/dl_lib/` 中的预编译库构建服务器。
## kvm_system 编译说明
@@ -11,16 +18,17 @@
2. 修改 `./build` 中 `MAIXCDK_PATH` 和 `NanoKVM_PATH`的路径
3. 执行 `./build kvm_system` 编译 kvm_system
4. 使用 `scp ./kvm_system/dist/kvm_system_release/kvm_system root@192.168.x.x:/kvmapp/kvm_system` 拷贝入 NanoKVM 测试
5. 使用 `./build add_to_kvmapp` 可以将可执行文件放入 `/kvmapp` 安装包内
5. 使用 `./build kvm_system add_to_kvmapp` 将可执行文件放入仓库的 `kvmapp/` 目录
6. 使用 `./build kvm_system clean` 可以清除 kvm_system 的编译
## kvm_vision 编译说明
> `kvm_vision` 是 NanoKVM 图像获取编码子系统的统称,使用MaixCDK编译出动态库供Go调用,使用 `kvm_vision_test` 编译和测试动态库
> `kvm_vision` 先构建 `libkvm_mmf.so`,再构建 MaixCDK 包装层 `libkvm.so` 和 `kvm_vision_test` 示例程序。
1. 编译前,请保证上述 1.`MaixCDK`环境已正确配置
2. 修改 `./build` 中 `MAIXCDK_PATH` 和 `NanoKVM_PATH`的路径
3. 执行 `./build kvm_vision` 编译 kvm_vision_test
4. 可使用 `kvm_vision_test/dist/kvm_vision_test_release/dl_lib/` 中的动态库测试
5. 使用 `./build add_to_kvmapp` 可以将动态库放入 `/kvmapp` 安装包内
6. 使用 `./build kvm_vision clean` 可以清除 kvm_system 的编译
3. 执行 `./build kvm_vision` 编译两份库和示例程序。库的编译和安装目录分别为 `kvm_vision_test/build/kvm_mmf-build/`、`kvm_vision_test/build/kvm_mmf-install/`。
4. 构建后的动态库位于 `kvm_vision_test/dist/kvm_vision_test_release/dl_lib/`。
5. 执行 `./build kvm_vision add_to_kvmapp`,将库放入仓库的 `kvmapp/server/dl_lib/`。
6. 修改视频库接口后,用同一批构建的两份库更新 `server/dl_lib/libkvm.so` 和 `server/dl_lib/libkvm_mmf.so`。服务器直接使用这些预编译库,编译步骤见[服务器说明](../../server/README_ZH.md#编译部署)。
7. 执行 `./build kvm_vision clean` 清理构建产物,包括上述库的编译和安装目录。

View File

@@ -1,4 +1,4 @@
list(APPEND ADD_REQUIREMENTS vision basic peripheral)
list(APPEND ADD_REQUIREMENTS kvm_mmf basic peripheral)
list(APPEND ADD_INCLUDE "include")
list(APPEND ADD_LINK_DEFINITIONS_PRIVATE -Wl,--as-needed)

View File

@@ -1,79 +1,43 @@
#ifndef KVM_VISION_H_
#define KVM_VISION_H_
#include "kvm_mmf.hpp"
#include "maix_camera.hpp"
#include "maix_basic.hpp"
#include "maix_i2c.hpp"
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#include <fcntl.h> /* low-level i/o */
#include <unistd.h>
#include <string.h>
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <pthread.h>
#include <time.h>
// #include "kvm_mmf.hpp"
#define IMG_BUFFER_FULL -3
#define IMG_VENC_ERROR -2
#define IMG_NOT_EXIST -1
#define IMG_MJPEG_TYPE 0
#define IMG_H264_TYPE_IF 3
#define IMG_H264_TYPE_PF 4
#define IMG_BUFFER_FULL -3
#define IMG_VENC_ERROR -2
#define IMG_NOT_EXIST -1
#define IMG_MJPEG_TYPE 0
#define IMG_H264_TYPE_SPS 1
#define IMG_H264_TYPE_PPS 2
#define IMG_H264_TYPE_IF 3
#define IMG_H264_TYPE_PF 4
typedef struct {
uint8_t *data;
uint32_t size;
uint64_t timestamp;
uint64_t token;
uint8_t codec;
} kvmv_frame_t;
#define NORMAL_RES 0
#define NEW_RES 1
#define UNSUPPORT_RES 2
#define UNKNOWN_RES 3
#define ERROR_RES 4
void kvmv_init(uint8_t debug_info);
void kvmv_deinit(void);
void kvmv_init(uint8_t _debug_info_en = 0);
void set_venc_auto_recyc(uint8_t _enable);
/**********************************************************************************
* @name kvmv_read_img
* @author Sipeed BuGu
* @date 2024/10/25
* @version R1.0
* @brief Acquire the encoded image with auto init
* @param _width @input: Output image width
* @param _height @input: Output image height
* @param _type @input: Encode type
* @param _qlty @input: MJPEG: (50-100) | H264: (500-10000)
* @param _pp_kvm_data @output: Encode data
* @param _p_kvmv_data_size @output: Encode data size
* @return
-7: HDMI INPUT RES ERROR
-6: Unsupported resolution, please modify it in the host settings.
-5: Retrieving image, please wait
-4: Modifying image resolution, please wait
-3: img buffer full
-2: VENC Errorl
-1: No images were acquired
0: Acquire MJPEG encoded images
1: Acquire H264 encoded images(SPS)[Deprecated]
2: Acquire H264 encoded images(PPS)[Deprecated]
3: Acquire H264 encoded images(I)
4: Acquire H264 encoded images(P)
5: IMG not changed
**********************************************************************************/
int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _qlty, uint8_t** _pp_kvm_data, uint32_t* _p_kvmv_data_size);
int free_kvmv_data(uint8_t ** _pp_kvm_data);
void free_all_kvmv_data();
void set_h264_gop(uint8_t _gop);
void set_frame_detact(uint8_t _frame_detact);
void kvmv_deinit();
uint8_t kvmv_hdmi_control(uint8_t _en);
// type: 0 = MJPEG, 1 = H.264. A zero width/height follows the input size.
// quality: MJPEG 50..100, H.264 500..10000 Kbps. fps: 1..60.
// Returns -7 input error, -6 unsupported size, -5 busy, -4 reconfiguring,
// -3 allocation failure, -2 encoder error, -1 no frame, 0 JPEG, 3 IDR,
// 4 P-frame, or 5 unchanged JPEG. Release every successful frame once.
int kvmv_read_img(uint16_t width, uint16_t height, uint8_t type,
uint16_t quality, uint16_t fps, kvmv_frame_t *frame);
// Frees the copied bytes and releases the encoder lease. Safe after shutdown.
void kvmv_release_frame(kvmv_frame_t *frame);
void kvmv_stop_stream(uint8_t type);
void set_h264_gop(uint8_t gop);
void set_frame_detact(uint8_t interval);
uint8_t kvmv_hdmi_control(uint8_t enable);
uint8_t kvmv_hdmi_signal_active(void);
#ifdef __cplusplus

View File

@@ -1,45 +1,29 @@
/**
* 待解决的问题:
* // 分辨率跟随输出
* // 自动切换分辨率
* // HDMI分辨率问题
* // H264输入空图,VENC容易炸问题
* // MJPEG/H264切换时卡退
* // 再加一条手动清空全部内存
* // deinit时free全部内存
* // free 错内存时会炸的问题
*/
#include "kvm_vision.h"
#include "maix_basic.hpp"
#include "maix_i2c.hpp"
#include "vi_state_shared.hpp"
#include "internal/vi_state_writer.hpp"
#include "kvm_mmf.hpp"
#include <atomic>
#include <cstdarg>
#include <mutex>
#include <pthread.h>
#include <errno.h>
#include <sys/stat.h>
#define default_venc_chn 1
#define VENC_MJPEG 0
#define VENC_H264 1
#define KVMV_MAX_TRY_NUM 2
#define vi_min_width 32
#define vi_min_height 3
#define vi_max_width 1920
#define vi_max_height 1080
#define Farame_sample_size 76800 // 320*240
#define default_vi_width 1920
#define default_vi_height 1080
#define default_vpss_width 1920
#define default_vpss_height 1080
#define default_venc_type VENC_MJPEG
#define default_mjpeg_qlty 60
#define default_h264_qlty 1000
#define default_h264_gop 30
#define fresh_frame_discard_count 5
#define kvmv_data_buffer_size 4
#define Try_rounds_HDMI_err_res 5
#define vi_width_path "/kvmapp/kvm/width"
@@ -56,7 +40,9 @@
#define LT6911_READ 0xFF
#define LT6911_WRITE 0x00
pthread_mutex_t vi_mutex;
std::mutex capture_mutex[2];
pthread_t detection_thread, watchdog_thread;
bool detection_started = false, watchdog_started = false;
pthread_mutex_t hdmi_signal_mutex = PTHREAD_MUTEX_INITIALIZER;
static char NanoKVM_edit[] = {
@@ -75,56 +61,30 @@ using namespace maix::sys;
using namespace maix::peripheral;
i2c::I2C LT6911_i2c(4, i2c::Mode::MASTER);
typedef struct {
uint16_t vi_width = default_vi_width;
uint16_t vi_height = default_vi_height;
uint16_t vpss_width = default_vpss_width;
uint16_t vpss_height = default_vpss_height;
uint8_t venc_type;
uint16_t qlty;
uint8_t stream_stop;
uint8_t frame_detact = 0;
uint8_t display;
uint8_t reinit_flag = 1;
uint8_t reopen_cam_flag = 0;
uint8_t hdmi_cable_state = 0;
uint8_t try_exit_thread = 0;
uint8_t thread_is_running = 0;
uint8_t Auto_res = 0;
enum ResolutionState { NORMAL_RES, NEW_RES, UNSUPPORT_RES, UNKNOWN_RES, ERROR_RES };
struct {
std::atomic<uint16_t> vi_width{default_vi_width};
std::atomic<uint16_t> vi_height{default_vi_height};
uint8_t frame_detact = 0;
std::atomic<bool> reopen_cam_flag{false};
std::atomic<bool> try_exit_thread{false};
uint8_t hdmi_version = 0;
uint8_t hw_version = 0;
uint8_t hdmi_stop_flag = 0;
uint8_t hdmi_reading_flag = 0;
std::atomic<bool> hdmi_stop_flag{false};
std::atomic<bool> hdmi_reading_flag{false};
uint8_t hdmi_mode = 0;
uint8_t hdmi_res_type = 0;
uint8_t hdmi_res_err = 0;
std::atomic<uint8_t> hdmi_res_type{NORMAL_RES};
std::atomic<uint8_t> hdmi_res_err{NORMAL_RES};
uint8_t hdmi_try_rounds = 0;
uint8_t vi_detect_state = 0;
uint8_t venc_auto_recyc = 0;
uint8_t fresh_frame_count = 0;
} kvmv_cfg_t;
std::atomic<uint8_t> vi_detect_state{0};
} kvmv_cfg;
typedef struct {
uint8_t* p_img_data = NULL;
uint8_t img_data_type = 0;
uint32_t img_data_size = 0;
} kvmv_data_t;
typedef struct {
uint8_t mmf_venc_chn;
uint8_t enc_h264_running;
uint8_t enc_h264_init;
mmf_venc_cfg_t kvm_venc_cfg;
} kvm_venc_t;
camera::Camera *cam = new camera::Camera(default_vpss_width, default_vpss_height, image::FMT_YVU420SP);
// camera::Camera *cam = new camera::Camera(320, 240, image::FMT_RGB888);
kvmv_cfg_t kvmv_cfg;
kvmv_data_t kvmv_data_buffer[kvmv_data_buffer_size];
uint8_t kvmv_data_buffer_index = 0;
struct {
uint64_t hash = 0;
uint8_t count = 0;
bool unchanged = false;
} jpeg_detection;
uint8_t debug_en = 0;
uint8_t last_vi_state_code = 0;
@@ -200,16 +160,13 @@ static void set_hdmi_capture_enabled(uint8_t enabled)
pthread_mutex_unlock(&hdmi_signal_mutex);
}
static void set_hdmi_detection_state(uint8_t active)
{
__atomic_store_n(&kvmv_cfg.hdmi_cable_state, active != 0 ? 1 : 0, __ATOMIC_RELEASE);
set_hdmi_signal_state(active);
}
void debug(const char *format, ...)
{
if(debug_en){
printf(format);
va_list args;
va_start(args, format);
vprintf(format, args);
va_end(args);
}
}
@@ -219,17 +176,10 @@ uint8_t refresh_vi_state()
// that the publication cadence measures wall-clock time, not read time plus it.
last_vi_state_refresh_ms = vi_state_shared::monotonic_ms();
last_vi_state_code = vi_state_shared::refresh();
set_hdmi_detection_state(last_vi_state_code == 1 || last_vi_state_code >= 3);
set_hdmi_signal_state(last_vi_state_code == 1 || last_vi_state_code >= 3);
return last_vi_state_code;
}
uint8_t to_roll(int8_t _input)
{
if(_input < 0) return _input + kvmv_data_buffer_size;
if(_input >= kvmv_data_buffer_size) return _input - kvmv_data_buffer_size;
return _input;
}
int maxmin_data(int _max, int _min, int _data)
{
if(_data > _max) return _max;
@@ -237,17 +187,6 @@ int maxmin_data(int _max, int _min, int _data)
return _data;
}
kvmv_data_t* get_save_buffer()
{
kvmv_data_buffer_index = to_roll(kvmv_data_buffer_index + 1);
// debug("[kvmv]kvmv_data_buffer_index = %d\n", kvmv_data_buffer_index);
// debug("[kvmv]kvmv_data_buffer.p_img_data = %d\n", kvmv_data_buffer[kvmv_data_buffer_index].p_img_data);
if(kvmv_data_buffer[kvmv_data_buffer_index].p_img_data == NULL){
return &kvmv_data_buffer[kvmv_data_buffer_index];
}
return NULL;
}
// ====HDMI RES==================================================
uint16_t hdmi_res_list[][2] = {
@@ -435,12 +374,12 @@ int get_manual_resolution(void)
// res change ?
if(kvmv_cfg.vi_width != tmp_width){
kvmv_cfg.vi_width = tmp_width;
printf("[kvmk] get new width = %d\n", kvmv_cfg.vi_width);
printf("[kvmk] get new width = %d\n", kvmv_cfg.vi_width.load());
res = 1;
}
if(kvmv_cfg.vi_height != tmp_height){
kvmv_cfg.vi_height = tmp_height;
printf("[kvmk] get new height = %d\n", kvmv_cfg.vi_height);
printf("[kvmk] get new height = %d\n", kvmv_cfg.vi_height.load());
res = 1;
}
return res;
@@ -452,12 +391,12 @@ uint8_t auto_try_res()
uint8_t err_code;
uint8_t auto_trying_times = 0;
for (auto_trying_times = 0; auto_trying_times < sizeof(hdmi_res_list)/4; auto_trying_times++){
for (auto_trying_times = 0; auto_trying_times < sizeof(hdmi_res_list)/4 && !kvmv_cfg.try_exit_thread.load(); auto_trying_times++){
err_code = refresh_vi_state();
switch(err_code){
case 0:
// shouldn't be possible to run here
cam->restart(default_vpss_width, default_vpss_height, image::FMT_YVU420SP);
kvm_mmf::restart();
printf("[kvmv] VI not init\n");
break;
case 1:
@@ -484,8 +423,8 @@ uint8_t auto_try_res()
kvmv_cfg.vi_width = hdmi_res_list[auto_trying_times][0];
kvmv_cfg.vi_height = hdmi_res_list[auto_trying_times][1];
printf("[kvmv] restart cam...\n");
cam->restart(default_vpss_width, default_vpss_height, image::FMT_YVU420SP);
printf("[kvmv] restart video...\n");
kvm_mmf::restart();
time::sleep_ms(50);
break;
case 7: // Unknown reason
@@ -1121,7 +1060,7 @@ void* watchdog_sf_feed(void * arg)
{
while(true)
{
if(kvmv_cfg.try_exit_thread == 1)
if(kvmv_cfg.try_exit_thread.load() == 1)
break;
time::sleep_ms(500);
if (watchdog_sf_is_open()){
@@ -1133,6 +1072,7 @@ void* watchdog_sf_feed(void * arg)
vision_update_watchdog();
}
}
return nullptr;
}
void get_hdmi_version()
@@ -1172,6 +1112,16 @@ void get_hdmi_version()
}
}
static uint8_t update_hdmi_resolution()
{
uint16_t width = kvmv_cfg.vi_width;
uint16_t height = kvmv_cfg.vi_height;
uint8_t result = lt6911_get_csi_res(&width, &height);
kvmv_cfg.vi_width = width;
kvmv_cfg.vi_height = height;
return result;
}
void* vi_subsystem_detection(void * arg)
{
uint64_t __attribute__((unused)) int_time;
@@ -1184,7 +1134,6 @@ void* vi_subsystem_detection(void * arg)
uint8_t falling_times = 0;
uint8_t cam_need_restart = 0;
uint8_t auto_change_mode = 0;
kvmv_cfg.thread_is_running = 1;
if(access("/proc/lt_int", F_OK) != 0){
time::sleep_ms(10);
debug("[hdmi]/proc/lt_int not ok\n");
@@ -1196,7 +1145,7 @@ void* vi_subsystem_detection(void * arg)
last_vi_state_refresh_ms = vi_state_shared::monotonic_ms() - vi_state_publish_interval_ms;
while(true)
{
if(kvmv_cfg.try_exit_thread == 1)
if(kvmv_cfg.try_exit_thread.load() == 1)
break;
uint8_t get_new_hdmi_mode = get_hdmi_mode();
@@ -1258,8 +1207,8 @@ void* vi_subsystem_detection(void * arg)
if(lt6911_get_hdmi_res()){
// hdmi get res
debug("[hdmi] C HDMI cable insertion!\n");
set_hdmi_detection_state(1);
kvmv_cfg.hdmi_res_type = lt6911_get_csi_res(&kvmv_cfg.vi_width, &kvmv_cfg.vi_height);
set_hdmi_signal_state(1);
kvmv_cfg.hdmi_res_type = update_hdmi_resolution();
if (kvmv_cfg.hdmi_res_type == NEW_RES) kvmv_cfg.reopen_cam_flag = 1;
else if (kvmv_cfg.hdmi_res_type == UNKNOWN_RES){
/* Move HDMI resolution modification directly
@@ -1270,7 +1219,7 @@ void* vi_subsystem_detection(void * arg)
} else {
// HDMI res = 0*0/x*0
debug("[hdmi] C HDMI cable unplugged!\n");
set_hdmi_detection_state(0);
set_hdmi_signal_state(0);
}
lt6911_disable();
}
@@ -1283,8 +1232,8 @@ void* vi_subsystem_detection(void * arg)
if(lt6911_get_hdmi_res()){
// hdmi get res
debug("[hdmi] UXC HDMI cable insertion!\n");
set_hdmi_detection_state(1);
kvmv_cfg.hdmi_res_type = lt6911_get_csi_res(&kvmv_cfg.vi_width, &kvmv_cfg.vi_height);
set_hdmi_signal_state(1);
kvmv_cfg.hdmi_res_type = update_hdmi_resolution();
if (kvmv_cfg.hdmi_res_type == NEW_RES) kvmv_cfg.reopen_cam_flag = 1;
else if (kvmv_cfg.hdmi_res_type == UNKNOWN_RES){
/* Move HDMI resolution modification directly
@@ -1295,7 +1244,7 @@ void* vi_subsystem_detection(void * arg)
} else {
// HDMI res = 0*0/x*0
debug("[hdmi] UXC HDMI cable unplugged!\n");
set_hdmi_detection_state(0);
set_hdmi_signal_state(0);
}
lt6911_disable();
}
@@ -1308,8 +1257,8 @@ void* vi_subsystem_detection(void * arg)
if(lt6911_get_hdmi_res()){
// hdmi get res
debug("[hdmi] D HDMI cable insertion!\n");
set_hdmi_detection_state(1);
kvmv_cfg.hdmi_res_type = lt6911_get_csi_res(&kvmv_cfg.vi_width, &kvmv_cfg.vi_height);
set_hdmi_signal_state(1);
kvmv_cfg.hdmi_res_type = update_hdmi_resolution();
if (kvmv_cfg.hdmi_res_type == NEW_RES) kvmv_cfg.reopen_cam_flag = 1;
else if (kvmv_cfg.hdmi_res_type == UNKNOWN_RES){
/* Move HDMI resolution modification directly
@@ -1320,7 +1269,7 @@ void* vi_subsystem_detection(void * arg)
} else {
// HDMI res = 0*0/x*0
debug("[hdmi] D HDMI cable unplugged!\n");
set_hdmi_detection_state(0);
set_hdmi_signal_state(0);
}
lt6911_disable();
}
@@ -1392,8 +1341,8 @@ void* vi_subsystem_detection(void * arg)
if (kvmv_cfg.vi_detect_state == 1){
// detect_res
if (get_manual_resolution()) {
debug("[kvmv] restart cam...\n");
cam->restart(default_vpss_width, default_vpss_height, image::FMT_YVU420SP);
debug("[kvmv] restart video...\n");
kvm_mmf::restart();
}
// Sample after a manual resolution change.
@@ -1442,554 +1391,121 @@ void* vi_subsystem_detection(void * arg)
time::sleep_ms(10);
}
kvmv_cfg.thread_is_running = 0;
}
int sync_vi_res()
{
int res = 0;
uint8_t RW_Data[35];
FILE *fp;
int file_size;
uint16_t tmp16;
// vi_width:
if (access(vi_width_path, F_OK) != 0){
kvmv_cfg.vi_width = default_vi_width;
kvmv_cfg.vi_height = default_vi_height;
res = -1;
return res;
} else {
fp = fopen(vi_width_path, "r");
fseek(fp, 0, SEEK_END);
file_size = ftell(fp);
fseek(fp, 0, SEEK_SET);
fread(RW_Data, sizeof(char), file_size, fp);
fclose(fp);
RW_Data[file_size] = 0;
tmp16 = atoi((char*)RW_Data);
if(tmp16 != kvmv_cfg.vi_width){
kvmv_cfg.vi_width = tmp16;
debug("[hdmi] Get new HDMI width = %d\r\n", kvmv_cfg.vi_width);
res = 1;
}
}
// vi_height:
if (access(vi_height_path, F_OK) != 0){
kvmv_cfg.vi_height = default_vi_height;
res = -1;
return res;
} else {
fp = fopen(vi_height_path, "r");
fseek(fp, 0, SEEK_END);
file_size = ftell(fp);
fseek(fp, 0, SEEK_SET);
fread(RW_Data, sizeof(char), file_size, fp);
fclose(fp);
RW_Data[file_size] = 0;
tmp16 = atoi((char*)RW_Data);
if(tmp16 != kvmv_cfg.vi_height){
kvmv_cfg.vi_height = tmp16;
debug("[hdmi] Get new HDMI height = %d\r\n", kvmv_cfg.vi_height);
res = 1;
}
}
return res;
}
uint8_t frame_changed(image::Image *raw)
{
static int raw_size = 0;
static uint8_t Farame_sample[Farame_sample_size] = {0};
uint8_t ret = 0;
uint8_t sample_byte;
if(raw->data_size() != raw_size){
raw_size = raw->data_size();
ret = 1;
}
int Detection_Pixel_Interval = raw_size/(Farame_sample_size*1.5);
for(int i = 0; i < Farame_sample_size; i ++){
// printf("[kvmv] i = %d\n", i);
if(i >= raw_size){
ret = 0;
}
sample_byte = *(uint8_t*)(raw->data()+(i*Detection_Pixel_Interval));
if(sample_byte != Farame_sample[i]){
Farame_sample[i] = sample_byte;
ret = 1;
}
}
return ret;
}
bool jpg_dump(kvmv_data_t* dump_to, image::Image *raw)
{
if(dump_to == NULL || raw == NULL || raw->data() == NULL || raw->data_size() == 0){
return false;
}
dump_to->p_img_data = (uint8_t *)malloc(raw->data_size());
if(dump_to->p_img_data == NULL){
dump_to->img_data_size = 0;
dump_to->img_data_type = 0;
return false;
}
dump_to->img_data_size = raw->data_size();
dump_to->img_data_type = VENC_MJPEG;
memcpy(dump_to->p_img_data, (uint8_t *)raw->data(), raw->data_size());
return true;
return nullptr;
}
uint8_t kvmvenc_gop = default_h264_gop;
kvm_venc_t kvm_venc;
mmf_venc_cfg_t cfg;
void init_venc_h264(uint16_t _width, uint16_t _height, uint16_t _qlty)
void set_h264_gop(uint8_t gop)
{
cfg.type = 2; //1, h265, 2, h264
cfg.w = _width;
cfg.h = _height;
cfg.fmt = mmf_invert_format_to_mmf(image::Format::FMT_YVU420SP);
cfg.jpg_quality = 0; // unused
cfg.gop = kvmvenc_gop;
cfg.intput_fps = 60;
cfg.output_fps = 60;
cfg.bitrate = _qlty; // 码率
kvm_venc.mmf_venc_chn = default_venc_chn;
kvm_venc.enc_h264_init = 0;
kvm_venc.kvm_venc_cfg = cfg;
// if(mmf_vdec_is_used(kvm_venc.mmf_venc_chn)){
mmf_del_venc_channel(kvm_venc.mmf_venc_chn);
// }
if (0 != mmf_add_venc_channel(kvm_venc.mmf_venc_chn, &kvm_venc.kvm_venc_cfg)) {
err::check_raise(err::ERR_RUNTIME, "mmf venc init failed!");
}
kvm_venc.enc_h264_init = 1;
// init_kvm_h264_stream(&kvm_h264_stream, mmf_stream_buf);
// init_h264_stream_struct(&kvm_h264_stream);
__atomic_store_n(&kvmvenc_gop, (uint8_t)maxmin_data(100, 1, gop), __ATOMIC_RELEASE);
}
int h264_stream_dump(kvmv_data_t* dump_to, mmf_stream_t* dump_from)
void set_frame_detact(uint8_t interval)
{
static int8_t I_Frame_index = -1;
// debug("[kvmv]dump_from->count = %d\n", dump_from->count);
if (dump_from->count == 3) {
std::lock_guard<std::mutex> lock(capture_mutex[VENC_MJPEG]);
kvmv_cfg.frame_detact = maxmin_data(100, 0, interval);
jpeg_detection = {};
}
dump_to->p_img_data = (uint8_t *)malloc(dump_from->data_size[0]+dump_from->data_size[1]+dump_from->data_size[2]);
dump_to->img_data_size = dump_from->data_size[0]+dump_from->data_size[1]+dump_from->data_size[2];
dump_to->img_data_type = IMG_H264_TYPE_IF;
memcpy(dump_to->p_img_data, dump_from->data[0], dump_from->data_size[0]);
memcpy(dump_to->p_img_data+dump_from->data_size[0], dump_from->data[1], dump_from->data_size[1]);
memcpy(dump_to->p_img_data+dump_from->data_size[0]+dump_from->data_size[1], dump_from->data[2], dump_from->data_size[2]);
debug("[kvmv]SPS size = %d\n", dump_from->data_size[0]);
debug("[kvmv]PPS size = %d\n", dump_from->data_size[1]);
debug("[kvmv]I-Frame size = %d\n", dump_from->data_size[2]);
return IMG_H264_TYPE_IF;
} else if (dump_from->count == 1) {
// debug("[kvmv]dump P-Frame\r\n");
I_Frame_index = -1;
dump_to->p_img_data = (uint8_t *)malloc(dump_from->data_size[0]);
dump_to->img_data_size = dump_from->data_size[0];
dump_to->img_data_type = IMG_H264_TYPE_PF;
memcpy(dump_to->p_img_data, dump_from->data[0], dump_from->data_size[0]);
return IMG_H264_TYPE_PF;
} else {
debug("[kvmv]venc error!\r\n");
return IMG_VENC_ERROR;
void kvmv_init(uint8_t debug_info)
{
debug_en = debug_info != 0;
kvmv_cfg.try_exit_thread = false;
if (!kvm_mmf::initialize()) {
fprintf(stderr, "[kvmv] video initialization failed\n");
}
if (!detection_started) {
detection_started = pthread_create(&detection_thread, nullptr, vi_subsystem_detection, nullptr) == 0;
}
if (!watchdog_started) {
watchdog_started = pthread_create(&watchdog_thread, nullptr, watchdog_sf_feed, nullptr) == 0;
}
}
void set_h264_gop(uint8_t _gop)
int kvmv_read_img(uint16_t width, uint16_t height, uint8_t type,
uint16_t quality, uint16_t fps, kvmv_frame_t *out)
{
kvm_venc.enc_h264_init = 0; // call
kvmvenc_gop = maxmin_data(100, 1, (int)_gop);
debug("[kvmv] set_h264_gop = %d\n", kvmvenc_gop);
}
void set_frame_detact(uint8_t _frame_detact)
{
uint8_t frame_detact = maxmin_data(100, 0, (int)_frame_detact);
kvmv_cfg.frame_detact = frame_detact;
debug("[kvmv] set_frame_detact = %d\n", kvmv_cfg.frame_detact);
kvmv_cfg.stream_stop = 0;
}
int8_t raw_to_h264(image::Image *raw, kvmv_data_t* ret_stream, uint16_t _qlty)
{
uint64_t __attribute__((unused)) start_time;
uint64_t __attribute__((unused)) frame_time;
int8_t ret = 0;
static uint8_t P_Frame_Count = 0;
// start_time = time::time_ms();
// log::info("getimg: %d \r\n", (int)(time::time_ms()));
mmf_stream_t _stream = {0};
if(kvm_venc.enc_h264_init != 1 || raw->width() != kvm_venc.kvm_venc_cfg.w || raw->height() != kvm_venc.kvm_venc_cfg.h || _qlty != kvm_venc.kvm_venc_cfg.bitrate){
debug("[kvmv]init_venc_h264 enc_h264_init = %d; raw->width() = %d | %d raw->height() = %d | %d \n",
kvm_venc.enc_h264_init,
raw->width(), kvm_venc.kvm_venc_cfg.w,
raw->height(), kvm_venc.kvm_venc_cfg.h);
init_venc_h264(raw->width(), raw->height(), _qlty);
debug("[kvmv]init_venc_h264 finish enc_h264_init = %d; raw->width() = %d | %d raw->height() = %d | %d \n",
kvm_venc.enc_h264_init,
raw->width(), kvm_venc.kvm_venc_cfg.w,
raw->height(), kvm_venc.kvm_venc_cfg.h);
// if(kvm_venc.enc_h264_init == 1){
// init_venc_h264(raw->width(), raw->height(), _qlty);
// } else {
// init_venc_h264(default_vpss_width, default_vpss_height, default_h264_qlty);
// }
if (!out) return IMG_VENC_ERROR;
*out = {};
if (type > VENC_H264) return IMG_VENC_ERROR;
std::lock_guard<std::mutex> lock(capture_mutex[type]);
if (kvmv_cfg.try_exit_thread || kvmv_cfg.hdmi_stop_flag) return IMG_NOT_EXIST;
if (kvmv_cfg.hdmi_res_err == ERROR_RES) return -7;
if (kvmv_cfg.hdmi_res_type == UNSUPPORT_RES) return -6;
if (kvmv_cfg.vi_detect_state == 1) return -4;
if (kvmv_cfg.reopen_cam_flag.exchange(false) && !kvm_mmf::restart()) return IMG_VENC_ERROR;
if (width == 0 || height == 0) {
width = kvmv_cfg.vi_width;
height = kvmv_cfg.vi_height;
}
// log::info("init(): %d \r\n", (int)(time::time_ms() - start_time));
if (mmf_venc_push(kvm_venc.mmf_venc_chn, (uint8_t *)raw->data(), raw->width(), raw->height(), mmf_invert_format_to_mmf(raw->format()))) {
mmf_del_venc_channel(kvm_venc.mmf_venc_chn);
kvm_venc.enc_h264_init = 0;
// rtmp->unlock();
debug("[kvmv]mmf venc push failed!\n");
// err::check_raise(err::ERR_RUNTIME, "mmf venc push failed!\r\n");
return -1;
}
// log::info("push(): %d \r\n", (int)(time::time_ms() - start_time));
if (mmf_venc_pop(kvm_venc.mmf_venc_chn, &_stream)) {
// log::error("mmf_venc_pop failed\n");
mmf_venc_free(kvm_venc.mmf_venc_chn);
mmf_del_venc_channel(kvm_venc.mmf_venc_chn);
kvm_venc.enc_h264_init = 0;
debug("[kvmv]mmf venc push failed!\n");
// rtmp->unlock();
return -1;
}
// log::info("pop(): %d \r\n", (int)(time::time_ms() - start_time));
ret = h264_stream_dump(ret_stream, &_stream);
mmf_venc_free(kvm_venc.mmf_venc_chn);
// log::info("dump(): %d \r\n", (int)(time::time_ms() - start_time));
quality = type == VENC_H264 ? maxmin_data(10000, 500, quality) : maxmin_data(100, 50, quality);
fps = maxmin_data(60, 1, fps);
auto codec = static_cast<kvm_mmf::Codec>(type);
kvm_mmf::Frame frame;
int result = kvm_mmf::acquire(codec, width, height, quality, fps,
__atomic_load_n(&kvmvenc_gop, __ATOMIC_ACQUIRE), &frame);
if (result < 0) return result;
// debug("[kvmv]_stream.data[0][4] = %d;\n", _stream.data[0][4]);
debug("[kvmv]Frame size = %d;\n", ret_stream->img_data_size);
if(ret == IMG_H264_TYPE_IF){
debug("[kvmv]================ GOP = %d ================\n", kvm_venc.kvm_venc_cfg.gop);
debug("[kvmv]SPS; PPS; I-Frame, I-Frame size = %d\n", ret_stream->img_data_size);
P_Frame_Count = 0;
} else if(ret == IMG_H264_TYPE_PF){
debug("[kvmv]P-Frame size = %d, P-count = %d\n", ret_stream->img_data_size, P_Frame_Count);
P_Frame_Count++;
}
debug("[kvmv]dump ret = %d\n", ret);
return ret;
}
void kvmv_init(uint8_t _debug_info_en)
{
pthread_t thread;
pthread_mutex_init(&vi_mutex, NULL);
if(_debug_info_en == 0) debug_en = 0;
else debug_en = 1;
// debug("[kvmv]kvmv_init - 1\r\n");
cam->hmirror(1);
cam->vflip(1);
cam->restart(default_vpss_width, default_vpss_height, image::FMT_YVU420SP);
for(int i = 0; i < kvmv_data_buffer_size; i++){
kvmv_data_buffer[i].p_img_data = NULL;
}
kvmv_cfg.try_exit_thread = 0;
// debug("[kvmv]kvmv_init - 2\r\n");
if(kvmv_cfg.thread_is_running == 1){
debug("[kvmv]thread is running!\r\n");
} else {
if (0 != pthread_create(&thread, NULL, vi_subsystem_detection, NULL)) {
debug("[kvmv]create vi_subsystem_detection thread failed!\r\n");
// return -1;
}
if (0 != pthread_create(&thread, NULL, watchdog_sf_feed, NULL)) {
debug("[kvmv]create watchdog_sf_feed thread failed!\r\n");
// return -1;
}
}
// debug("[kvmv]kvmv_init - 3\r\n");
}
uint8_t check_kvmv(uint8_t _try_num)
{
if(__atomic_load_n(&kvmv_cfg.hdmi_cable_state, __ATOMIC_ACQUIRE) == 0){
debug("[kvmv]HDMI Cable not exist!\n");
return 0;
}
if(_try_num >= KVMV_MAX_TRY_NUM){
debug("[kvmv]try_num >= KVMV_MAX_TRY_NUM!\n");
return 0;
}
// if(sync_vi_res() != 0){
if(kvmv_cfg.reopen_cam_flag == 1){
// vi size changed
kvmv_cfg.reopen_cam_flag = 0;
// cam->open(kvmv_cfg.vpss_width, kvmv_cfg.vpss_height, image::FMT_YVU420SP, 3);
cam->restart(default_vpss_width, default_vpss_height, image::FMT_YVU420SP);
debug("[kvmv]vi size changed, try again\n");
return 1;
}
debug("[kvmv]just try again\n");
return 1;
}
void set_venc_auto_recyc(uint8_t _enable)
{
if(_enable) kvmv_cfg.venc_auto_recyc = 1;
else kvmv_cfg.venc_auto_recyc = 0;
}
/**********************************************************************************
* @name kvmv_read_img
* @author Sipeed BuGu
* @date 2024/10/25
* @version R1.0
* @brief Acquire the encoded image with auto init
* @param _width @input: Output image width
* @param _height @input: Output image height
* @param _type @input: Encode type
* @param _qlty @input: MJPEG: (50-100) | H264: (500-10000)
* @param _pp_kvm_data @output: Encode data
* @param _p_kvmv_data_size @output: Encode data size
* @return
-7: HDMI INPUT RES ERROR
-6: Unsupported resolution, please modify it in the host settings.
-5: Retrieving image, please wait
-4: Modifying image resolution, please wait
-3: img buffer full
-2: VENC Errorl
-1: No images were acquired
0: Acquire MJPEG encoded images
1: Acquire H264 encoded images(SPS)[Deprecated]
2: Acquire H264 encoded images(PPS)[Deprecated]
3: Acquire H264 encoded images(I)
4: Acquire H264 encoded images(P)
5: IMG not changed
**********************************************************************************/
int kvmv_read_img(uint16_t _width, uint16_t _height, uint8_t _type, uint16_t _qlty, uint8_t** _pp_kvm_data, uint32_t* _p_kvmv_data_size)
{
*_pp_kvm_data = NULL;
*_p_kvmv_data_size = 0;
static uint8_t frame_undetact_count = 0;
// uint64_t __attribute__((unused)) start_time = time::time_ms();
debug("[kvmv]kvmv_read_img type = %d...\n", _type);
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += 1;
// pthread_mutex_lock(&vi_mutex); // Add lock
int mutex_res = pthread_mutex_timedlock(&vi_mutex, &ts);
if(mutex_res != 0){
return -5;
}
if (kvmv_cfg.hdmi_res_err == ERROR_RES){
pthread_mutex_unlock(&vi_mutex);
return -7;
}
if (kvmv_cfg.hdmi_res_type == UNSUPPORT_RES){
pthread_mutex_unlock(&vi_mutex);
return -6;
}
if (kvmv_cfg.vi_detect_state == 1){
pthread_mutex_unlock(&vi_mutex);
return -4;
}
uint8_t try_num = 0;
do {
if(kvmv_cfg.vpss_width != _width || kvmv_cfg.vpss_height != _height){
if(_width == 0 || _height == 0){
// Follow the HDMI output
kvmv_cfg.vpss_width = kvmv_cfg.vi_width;
kvmv_cfg.vpss_height = kvmv_cfg.vi_height;
if(kvmv_cfg.Auto_res == 0){
kvmv_cfg.Auto_res = 1;
cam->set_resolution(kvmv_cfg.vpss_width, kvmv_cfg.vpss_height);
kvmv_cfg.reinit_flag = 1;
}
} else {
kvmv_cfg.Auto_res = 0;
// Set the output
kvmv_cfg.vpss_width = _width;
kvmv_cfg.vpss_height = _height;
cam->set_resolution(kvmv_cfg.vpss_width, kvmv_cfg.vpss_height);
kvmv_cfg.reinit_flag = 1;
}
}
//
if (kvmv_cfg.reinit_flag == 1) {
cam->hmirror(1);
cam->vflip(1);
kvmv_cfg.reinit_flag = 0;
}
// debug("[kvmv]befor read img: %d \r\n", (int)(time::time_ms() - start_time));
image::Image *img = cam->read();
// debug("[kvmv]read img: %d \r\n", (int)(time::time_ms() - start_time));
if(img != NULL){
if(kvmv_cfg.fresh_frame_count != 0){
// Do not restart VI after HDMI idle. Reopening the MMF
// channel can exhaust the carveout heap when the detector
// thread is also transitioning. Consume queued frames
// instead; the camera buffer contains at most three frames.
kvmv_cfg.fresh_frame_count--;
delete img;
continue;
}
// frame detect
if(_type == VENC_MJPEG && kvmv_cfg.frame_detact != 0){
if(kvmv_cfg.stream_stop == 0){
frame_undetact_count++;
} else {
frame_undetact_count = kvmv_cfg.frame_detact;
}
if(frame_undetact_count == kvmv_cfg.frame_detact){
frame_undetact_count = 0;
if(frame_changed(img) == 0){
debug("[kvmv]frame not changed...\n");
kvmv_cfg.stream_stop = 1;
delete img;
pthread_mutex_unlock(&vi_mutex);
return 5;
} else {
kvmv_cfg.stream_stop = 0;
}
}
}
} else {
delete img;
debug("[kvmv]can`t get img...\n");
continue;
// pthread_mutex_unlock(&vi_mutex);
// return IMG_NOT_EXIST;
}
// debug("[kvmv]cheak img null?: %d \r\n", (int)(time::time_ms() - start_time));
// img exist
// Encode
if(kvmv_cfg.venc_type == VENC_MJPEG && kvmv_cfg.venc_type != _type){
if(kvmv_cfg.venc_auto_recyc == 1){
mmf_enc_jpg_deinit(0);
}
kvm_venc.enc_h264_init = 1;
}
if(kvmv_cfg.venc_type == VENC_H264 && kvmv_cfg.venc_type != _type){
if(kvmv_cfg.venc_auto_recyc == 1){
mmf_del_venc_channel(kvm_venc.mmf_venc_chn);
}
kvm_venc.enc_h264_init = 0;
}
kvmv_cfg.venc_type = _type;
if(kvmv_cfg.venc_type == VENC_MJPEG){
kvmv_data_t* p_kvmv_data = get_save_buffer();
if(p_kvmv_data == NULL){
// buffer full
delete img;
debug("[kvmv]jpg buffer full\n");
pthread_mutex_unlock(&vi_mutex);
return IMG_BUFFER_FULL;
}
image::Image *jpg = img->to_jpeg(maxmin_data(99, 51, (int)_qlty));
if(jpg == NULL || !jpg_dump(p_kvmv_data, jpg)){
delete jpg;
delete img;
debug("[kvmv]failed to allocate jpg buffer\n");
pthread_mutex_unlock(&vi_mutex);
return IMG_BUFFER_FULL;
}
delete jpg;
delete img;
*_pp_kvm_data = p_kvmv_data->p_img_data;
*_p_kvmv_data_size = p_kvmv_data->img_data_size;
pthread_mutex_unlock(&vi_mutex);
return IMG_MJPEG_TYPE;
} else if (kvmv_cfg.venc_type == VENC_H264){
int ret;
kvmv_data_t* p_kvmv_data = get_save_buffer();
if(p_kvmv_data == NULL){
// buffer full
delete img;
*_pp_kvm_data = NULL;
pthread_mutex_unlock(&vi_mutex);
return IMG_BUFFER_FULL;
}
// debug("[kvmv]get_save_buffer: %d \r\n", (int)(time::time_ms() - start_time));
ret = raw_to_h264(img, p_kvmv_data, maxmin_data(10000, 500, (int)_qlty));
// debug("[kvmv]venc raw_to_h264: %d \r\n", (int)(time::time_ms() - start_time));
delete img;
*_pp_kvm_data = p_kvmv_data->p_img_data;
*_p_kvmv_data_size = p_kvmv_data->img_data_size;
pthread_mutex_unlock(&vi_mutex);
return ret;
}
} while (check_kvmv(try_num++));
// debug("[kvmv]return: %d \r\n", (int)(time::time_ms() - start_time));
*_pp_kvm_data = NULL;
pthread_mutex_unlock(&vi_mutex);
return IMG_NOT_EXIST;
}
int free_kvmv_data(uint8_t ** _pp_kvm_data)
{
// debug("[kvmv]free_kvmv_data - 1\r\n");
for(int i = 0; i < kvmv_data_buffer_size; i++){
if(*_pp_kvm_data == kvmv_data_buffer[i].p_img_data){
// debug("[kvmv]free buffer : %d\n", *_pp_kvm_data);
if (*_pp_kvm_data != NULL){
// debug("[kvmv]free_kvmv_data - 2\r\n");
free(*_pp_kvm_data);
// debug("[kvmv]free_kvmv_data - 3\r\n");
kvmv_data_buffer[i].p_img_data = NULL;
uint8_t _type = kvmv_data_buffer[i].img_data_type;
return _type;
} else {
return IMG_NOT_EXIST;
// Detect unchanged JPEGs without reading raw VPSS pixels.
if (codec == kvm_mmf::MJPEG) {
uint8_t interval = kvmv_cfg.frame_detact;
if (interval == 0) {
jpeg_detection = {};
} else if (jpeg_detection.unchanged || ++jpeg_detection.count >= interval) {
jpeg_detection.count = 0;
uint64_t hash = 14695981039346656037ULL;
for (uint32_t i = 0; i < frame.size; ++i) hash = (hash ^ frame.data[i]) * 1099511628211ULL;
jpeg_detection.unchanged = hash == jpeg_detection.hash;
jpeg_detection.hash = hash;
if (jpeg_detection.unchanged) {
kvm_mmf::release(codec, frame.token);
free(frame.data);
return 5;
}
}
}
return IMG_NOT_EXIST;
*out = {frame.data, frame.size, frame.timestamp, frame.token, type};
return codec == kvm_mmf::MJPEG ? IMG_MJPEG_TYPE :
(frame.keyframe ? IMG_H264_TYPE_IF : IMG_H264_TYPE_PF);
}
void free_all_kvmv_data()
void kvmv_release_frame(kvmv_frame_t *frame)
{
for(int i = 0; i < kvmv_data_buffer_size; i++){
if(kvmv_data_buffer[i].p_img_data != NULL){
free(kvmv_data_buffer[i].p_img_data);
kvmv_data_buffer[i].p_img_data = NULL;
}
}
if (!frame || !frame->data) return;
kvm_mmf::release(static_cast<kvm_mmf::Codec>(frame->codec), frame->token);
free(frame->data);
*frame = {};
}
void kvmv_stop_stream(uint8_t type)
{
if (type > VENC_H264) return;
std::lock_guard<std::mutex> lock(capture_mutex[type]);
kvm_mmf::stop(static_cast<kvm_mmf::Codec>(type));
if (type == VENC_MJPEG) jpeg_detection = {};
}
void kvmv_deinit()
{
set_hdmi_capture_enabled(0);
pthread_mutex_destroy(&vi_mutex);
kvmv_cfg.try_exit_thread = 1;
cam->close();
mmf_deinit();
free_all_kvmv_data();
kvmv_cfg.try_exit_thread = true;
if (detection_started) { pthread_join(detection_thread, nullptr); detection_started = false; }
if (watchdog_started) { pthread_join(watchdog_thread, nullptr); watchdog_started = false; }
std::unique_lock<std::mutex> h264(capture_mutex[VENC_H264], std::defer_lock);
std::unique_lock<std::mutex> mjpeg(capture_mutex[VENC_MJPEG], std::defer_lock);
std::lock(h264, mjpeg);
kvm_mmf::deinitialize();
jpeg_detection = {};
}
uint8_t kvmv_hdmi_control(uint8_t _en)
{
if(_en == 0){
set_hdmi_capture_enabled(0);
kvm_mmf::stop(kvm_mmf::H264);
kvm_mmf::stop(kvm_mmf::MJPEG);
}
if(kvmv_cfg.hw_version == 0){
@@ -2030,11 +1546,6 @@ uint8_t kvmv_hdmi_control(uint8_t _en)
} else {
kvmv_cfg.hdmi_stop_flag = 0;
system("echo 1 > /sys/class/gpio/gpio451/value");
// Keep the existing VI channel and consume queued frames after idle.
// Reopening it here or from kvmv_read_img can exhaust the carveout
// heap while the HDMI detector is transitioning.
kvmv_cfg.fresh_frame_count = fresh_frame_discard_count;
set_hdmi_capture_enabled(1);
return 0;
}

View File

@@ -1,66 +1,71 @@
cmake_minimum_required(VERSION 3.16)
list(APPEND ADD_REQUIREMENTS basic ini)
list(APPEND ADD_INCLUDE "include")
# sophgo-middleware
set(middleware_src_path "${CMAKE_CURRENT_SOURCE_DIR}/../3rd_party/sophgo-middleware/sophgo-middleware")
set(source_dir "src")
append_srcs_dir(ADD_SRCS "${source_dir}")
# middleware
set(middleware_include_dir .
${middleware_src_path}/v2/component/panel/${CONFIG_SOPHGO_MIDDLEWARE_CHIP}
${middleware_src_path}/v2/include
${middleware_src_path}/v2/include/isp/${CONFIG_SOPHGO_MIDDLEWARE_CHIP}
${middleware_src_path}/v2/sample/common
${middleware_src_path}/v2/uapi
${middleware_src_path}/v2/3rdparty/inih
${middleware_src_path}/v2/modules/ive/include/
)
list(APPEND ADD_INCLUDE ${middleware_include_dir})
set_property(SOURCE ${middleware_include_dir} PROPERTY GENERATED 1)
append_srcs_dir(middleware_src_dir ${middleware_src_path}/v2/sample/common
${middleware_src_path}/v2/component/isp/sensor/sg200x/lontium_lt6911
)
list(APPEND ADD_SRCS ${middleware_src_dir}
"${source_dir}/kvm_mmf.cpp")
set_property(SOURCE ${middleware_src_dir} PROPERTY GENERATED 1)
set(middleware_static_lib_file "")
list(APPEND ADD_STATIC_LIB ${middleware_static_lib_file})
set_property(SOURCE ${middleware_static_lib_file} PROPERTY GENERATED 1)
set(mmf_lib_dir ${middleware_src_path}/v2/lib)
if(CONFIG_SOPHGO_MIDDLEWARE_C_LIBRARY STREQUAL "musl")
set(middleware_dynamic_lib_file ${mmf_lib_dir}/libcvi_bin.so
${mmf_lib_dir}/libae.so
${mmf_lib_dir}/libaf.so
${mmf_lib_dir}/libawb.so
${mmf_lib_dir}/libcvi_bin_isp.so
${mmf_lib_dir}/libisp_algo.so
${mmf_lib_dir}/libisp.so
${mmf_lib_dir}/libsys.so
${mmf_lib_dir}/libvdec.so
${mmf_lib_dir}/libvenc.so
${mmf_lib_dir}/libvpu.so
${mmf_lib_dir}/3rd/libini.so)
elseif(CONFIG_SOPHGO_MIDDLEWARE_C_LIBRARY STREQUAL "glibc")
set(middleware_dynamic_lib_file ${mmf_lib_dir}/libcvi_vb.so
${mmf_lib_dir}/libcvi_sys.so
${mmf_lib_dir}/libcvi_debug.so
)
else()
message(FATAL_ERROR "No sophgo middleware c library support, please check menuoconfig to select c library")
if(COMMAND register_component)
if(NOT EXISTS "$ENV{KVM_MMF_PREFIX}/lib/libkvm_mmf.so")
message(FATAL_ERROR "Build standalone kvm_mmf first and set KVM_MMF_PREFIX (support/sg2002/build does this)")
endif()
list(APPEND ADD_INCLUDE "$ENV{KVM_MMF_PREFIX}/include")
file(GLOB video_libraries "$ENV{KVM_MMF_PREFIX}/lib/*.so")
list(APPEND ADD_DYNAMIC_LIB ${video_libraries})
register_component()
return()
endif()
list(APPEND ADD_DYNAMIC_LIB ${middleware_dynamic_lib_file})
set_property(SOURCE ${middleware_dynamic_lib_file} PROPERTY GENERATED 1)
list(APPEND ADD_DEFINITIONS_PRIVATE -DSENSOR_LONTIUM_LT6911
-DSENSOR2_TYPE=LONTIUM_LT6911_2M_60FPS_8BIT)
project(kvm_mmf VERSION 1.0 LANGUAGES C CXX)
include(GNUInstallDirs)
find_package(Threads REQUIRED)
set(SOPHGO_MIDDLEWARE_DIR "" CACHE PATH "CVI SDK middleware root containing v2/")
set(SOPHGO_RUNTIME_DIR "" CACHE PATH "Firmware-matched SDK .so directory; defaults to middleware v2/lib")
set(SOPHGO_CHIP cv181x CACHE STRING "CVI SDK chip family")
option(KVM_MMF_INSTALL_SDK_LIBS "Install the SDK runtime libraries alongside kvm_mmf" ON)
if(NOT EXISTS "${SOPHGO_MIDDLEWARE_DIR}/v2/include/cvi_venc.h")
message(FATAL_ERROR "Set SOPHGO_MIDDLEWARE_DIR to the CVI SDK middleware root containing v2/include/cvi_venc.h")
endif()
get_filename_component(middleware "${SOPHGO_MIDDLEWARE_DIR}/v2" REALPATH)
if(SOPHGO_RUNTIME_DIR)
get_filename_component(runtime "${SOPHGO_RUNTIME_DIR}" REALPATH)
else()
set(runtime "${middleware}/lib")
endif()
set(common "${middleware}/sample/common")
set(sensor "${middleware}/component/isp/sensor/sg200x/lontium_lt6911")
register_component(DYNAMIC)
add_library(kvm_mmf SHARED
src/kvm_mmf.cpp
"${common}/sample_common_sys.c"
"${common}/sample_common_vi.c"
"${common}/sample_common_isp.c"
"${common}/sample_common_bin.c"
"${common}/sample_common_platform.c"
"${common}/sample_common_sensor.c"
"${sensor}/lt6911_cmos.c" "${sensor}/lt6911_sensor_ctl.c"
)
target_compile_features(kvm_mmf PRIVATE cxx_std_17)
target_compile_definitions(kvm_mmf PRIVATE SENSOR_LONTIUM_LT6911 SENSOR2_TYPE=LONTIUM_LT6911_2M_60FPS_8BIT)
target_compile_options(kvm_mmf PRIVATE -Wall -ffunction-sections -fdata-sections)
target_include_directories(kvm_mmf PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
)
target_include_directories(kvm_mmf PRIVATE
"${common}" "${middleware}/include" "${middleware}/uapi"
"${middleware}/include/isp/${SOPHGO_CHIP}"
"${middleware}/component/panel/${SOPHGO_CHIP}"
"${middleware}/3rdparty/inih"
)
foreach(name cvi_bin ae af awb cvi_bin_isp isp isp_algo sys venc vpu ini)
unset(sdk_${name} CACHE)
find_library(sdk_${name} NAMES ${name} PATHS "${runtime}" "${runtime}/3rd" NO_DEFAULT_PATH REQUIRED)
target_link_libraries(kvm_mmf PRIVATE "${sdk_${name}}")
if(KVM_MMF_INSTALL_SDK_LIBS AND sdk_${name} MATCHES "\\.so$")
install(FILES "${sdk_${name}}" DESTINATION ${CMAKE_INSTALL_LIBDIR})
endif()
endforeach()
target_link_libraries(kvm_mmf PRIVATE Threads::Threads m atomic)
target_link_options(kvm_mmf PRIVATE -Wl,--as-needed -Wl,--gc-sections -Wl,-z,defs -Wl,--no-allow-shlib-undefined)
set_target_properties(kvm_mmf PROPERTIES
C_VISIBILITY_PRESET hidden CXX_VISIBILITY_PRESET hidden VISIBILITY_INLINES_HIDDEN YES
BUILD_WITH_INSTALL_RPATH YES INSTALL_RPATH "\$ORIGIN")
install(TARGETS kvm_mmf LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR})
install(FILES include/kvm_mmf.hpp DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})

View File

@@ -0,0 +1,7 @@
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR riscv64)
set(CMAKE_C_COMPILER riscv64-unknown-linux-musl-gcc)
set(CMAKE_CXX_COMPILER riscv64-unknown-linux-musl-g++)
set(CMAKE_C_FLAGS_INIT "-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d")
set(CMAKE_CXX_FLAGS_INIT "${CMAKE_C_FLAGS_INIT}")

View File

@@ -1,71 +1,27 @@
#ifndef __KVM_MMF_HPP__
#define __KVM_MMF_HPP__
#pragma once
#include "stdint.h"
#include <stdint.h>
typedef struct {
uint8_t *data[8];
int data_size[8];
int count;
} mmf_stream_t;
// VI -> VPSS -> VENC, with no application queue of encoded frames.
namespace kvm_mmf __attribute__((visibility("default"))) {
typedef struct {
uint8_t type; // 0, jpg; 1, h265; 2, h264
int w;
int h;
int fmt;
uint8_t jpg_quality; // jpeg
int gop; // h264
int intput_fps; // h264
int output_fps; // h264
int bitrate; // h264
} mmf_venc_cfg_t;
enum Codec { MJPEG = 0, H264 = 1 };
// init sys
int mmf_init(void);
int mmf_deinit(void);
int mmf_try_deinit(bool force);
bool mmf_is_init(void);
struct Frame {
uint8_t *data = nullptr;
uint32_t size = 0;
uint64_t timestamp = 0; // SDK capture PTS, in microseconds.
uint64_t token = 0;
bool keyframe = false;
};
// manage vi channels(vi->vpssgroup->vpss->frame)
int mmf_get_vi_unused_channel(void);
int mmf_vi_init(void);
int mmf_vi_deinit(void);
int mmf_add_vi_channel_with_enc(int ch, int width, int height, int format);
int mmf_add_vi_channel(int ch, int width, int height, int format);
int mmf_del_vi_channel(int ch);
int mmf_del_vi_channel_all(void);
int mmf_reset_vi_channel(int ch, int width, int height, int format);
bool mmf_vi_chn_is_open(int ch);
int mmf_vi_aligned_width(int ch);
void mmf_set_vi_hmirror(int ch, bool en);
void mmf_set_vi_vflip(int ch, bool en);
void mmf_get_vi_hmirror(int ch, bool *en);
void mmf_get_vi_vflip(int ch, bool *en);
bool initialize();
bool restart();
void deinitialize();
void stop(Codec codec);
// get vi frame
int mmf_vi_frame_pop(int ch, void **data, int *len, int *width, int *height, int *format);
void mmf_vi_frame_free(int ch);
// Release the current VI frame immediately. mmf_vi_frame_free() defers
// release so the frame can be sent directly to VENC without a second copy.
void mmf_vi_frame_release(int ch);
int acquire(Codec codec, uint16_t width, uint16_t height, uint16_t quality,
uint16_t fps, uint8_t gop, Frame *frame);
void release(Codec codec, uint64_t token);
// invert format
int mmf_invert_format_to_maix(int mmf_format);
int mmf_invert_format_to_mmf(int maix_format);
// venc
int mmf_enc_jpg_init(int ch, int w, int h, int format, int quality);
int mmf_enc_jpg_deinit(int ch);
int mmf_enc_jpg_push(int ch, uint8_t *data, int w, int h, int format);
int mmf_enc_jpg_push_with_quality(int ch, uint8_t *data, int w, int h, int format, int quality);
int mmf_enc_jpg_pop(int ch, uint8_t **data, int *size);
int mmf_enc_jpg_free(int ch);
int mmf_add_venc_channel(int ch, mmf_venc_cfg_t *cfg);
int mmf_del_venc_channel(int ch);
int mmf_del_venc_channel_all();
int mmf_venc_push(int ch, uint8_t *data, int w, int h, int format);
int mmf_venc_pop(int ch, mmf_stream_t *stream);
int mmf_venc_free(int ch);
#endif // __KVM_MMF_HPP__
} // namespace kvm_mmf

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ maixcdk_build() {
DIST_PATH="./dist"
rm -rf $DIST_PATH
if [ "$input_value" = "from_zero" ]
then
maixcdk build <<EOF
@@ -24,8 +24,31 @@ EOF
fi
}
build_kvm_mmf() {
local component="$NanoKVM_PATH/support/sg2002/additional/kvm_mmf"
local middleware="${SOPHGO_MIDDLEWARE_DIR:-$MAIXCDK_PATH/components/3rd_party/sophgo-middleware/sophgo-middleware}"
local runtime="${SOPHGO_RUNTIME_DIR:-$NanoKVM_PATH/server/dl_lib}"
local build_dir="$Project_PATH/build/kvm_mmf-build"
export KVM_MMF_PREFIX="$Project_PATH/build/kvm_mmf-install"
cmake -S "$component" -B "$build_dir" \
-DCMAKE_TOOLCHAIN_FILE="$component/cmake/riscv64-musl.cmake" \
-DSOPHGO_MIDDLEWARE_DIR="$middleware" -DSOPHGO_RUNTIME_DIR="$runtime" \
-DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$KVM_MMF_PREFIX" || return 1
cmake --build "$build_dir" --parallel || return 1
cmake --install "$build_dir" || return 1
}
update_components() {
cp -rf "$NanoKVM_PATH/support/sg2002/additional/kvm" "$MAIXCDK_PATH/components/" || return 1
cp -rf "$NanoKVM_PATH/support/sg2002/additional/kvm_mmf" "$MAIXCDK_PATH/components/" || return 1
cp -f "$NanoKVM_PATH/support/sg2002/additional/peripheral/port/maixcam/maix_i2c.cpp" \
"$MAIXCDK_PATH/components/peripheral/port/maixcam/maix_i2c.cpp" || return 1
cp -f "$NanoKVM_PATH/support/sg2002/additional/sophgo-middleware/v2/sample/common/sample_common_sensor.c" \
"$MAIXCDK_PATH/components/3rd_party/sophgo-middleware/sophgo-middleware/v2/sample/common/" || return 1
}
chack_build(){
DIST_PATH="./dist"
DIST_PATH="./dist"
if [ -d "$DIST_PATH" ]
then
echo "SUCCESS"
@@ -41,15 +64,7 @@ maixcdk_clean(){
case "$1" in
update_lib)
# This script does not run under "set -e", and a release build relies on
# update_lib to guarantee the SDK components match the source tree. A copy
# that fails silently here would produce stale libraries, so fail loudly.
set -e
cp -rf $NanoKVM_PATH/support/sg2002/additional/kvm $MAIXCDK_PATH/components
cp -rf $NanoKVM_PATH/support/sg2002/additional/kvm_mmf $MAIXCDK_PATH/components
cp -rf $NanoKVM_PATH/support/sg2002/additional/vision $MAIXCDK_PATH/components
cp -f $NanoKVM_PATH/support/sg2002/additional/peripheral/port/maixcam/maix_i2c.cpp $MAIXCDK_PATH/components/peripheral/port/maixcam/maix_i2c.cpp
cp -f $NanoKVM_PATH/support/sg2002/additional/sophgo-middleware/v2/sample/common/sample_common_sensor.c $MAIXCDK_PATH/components/3rd_party/sophgo-middleware/sophgo-middleware/v2/sample/common/
update_components || exit 1
echo -e "\e[36m[build] \e[32mupdate_lib ok!\e[0m"
exit 0
@@ -106,37 +121,34 @@ then
if [ ! -e "$MAIXCDK_PATH/components/kvm" ]
then
echo -e "\e[36m[build] \e[32mDetected that the library has not been modified; first, move the library\e[0m"
mkdir $NanoKVM_PATH/support/sg2002/additional/original
mv -f $MAIXCDK_PATH/components/vision $NanoKVM_PATH/support/sg2002/additional/original
# mv $MAIXCDK_PATH/components/maixcam_lib $NanoKVM_PATH/support/sg2002/additional/original
mv -f $MAIXCDK_PATH/components/peripheral/port/maixcam/maix_i2c.cpp $NanoKVM_PATH/support/sg2002/additional/original
mv -f $MAIXCDK_PATH/components/3rd_party/sophgo-middleware/sophgo-middleware/v2/sample/common/sample_common_sensor.c $NanoKVM_PATH/support/sg2002/additional/original
cp -rf $NanoKVM_PATH/support/sg2002/additional/kvm $MAIXCDK_PATH/components
cp -rf $NanoKVM_PATH/support/sg2002/additional/kvm_mmf $MAIXCDK_PATH/components
cp -rf $NanoKVM_PATH/support/sg2002/additional/vision $MAIXCDK_PATH/components
cp -f $NanoKVM_PATH/support/sg2002/additional/peripheral/port/maixcam/maix_i2c.cpp $MAIXCDK_PATH/components/peripheral/port/maixcam/maix_i2c.cpp
cp -f $NanoKVM_PATH/support/sg2002/additional/sophgo-middleware/v2/sample/common/sample_common_sensor.c $MAIXCDK_PATH/components/3rd_party/sophgo-middleware/sophgo-middleware/v2/sample/common/
echo -e "\e[36m[build] \e[32mMove the library completed!\e[0m"
fi
cd $Project_PATH
if [ -f $JSON_PATH ]
update_components || exit 1
cd "$Project_PATH"
if [ -f "$JSON_PATH" ] && ! grep -q "$Platform_Char" "$JSON_PATH"; then
maixcdk_clean || exit 1
fi
if [ "$Project_Name" = "kvm_vision" ]; then
build_kvm_mmf || exit 1
fi
if [ -f "$JSON_PATH" ]
then
if grep -q $Platform_Char $JSON_PATH; then
maixcdk_build
else
maixcdk_clean
maixcdk_build "from_zero"
fi
maixcdk_build
else
maixcdk_build "from_zero"
fi
RES=$(chack_build)
if [ "$RES" = "SUCCESS" ]
then
echo -e "\e[36m[build] \e[32mBuild completed! Use \e[34m./build $Project_Name add_to_kvmapp\e[32m to add it to kvmapp.\e[0m"
@@ -173,7 +185,7 @@ then
else
echo -e "\e[36m[build] \e[32mkvm_vision has been moved to kvmapp.\e[0m"
fi
else
echo -e "\e[36m[build] \e[31mThe source file does not exist. Please compile using \e[34m./build kvm_system \e[31mfirst.\e[0m"
exit 1

View File

@@ -10,14 +10,14 @@ using namespace maix::sys;
extern kvm_sys_state_t kvm_sys_state;
extern kvm_oled_state_t kvm_oled_state;
static void write_hdmi_state(uint8_t active)
static bool write_hdmi_state(uint8_t active)
{
char temp_path[] = "/kvmapp/kvm/.state.init.XXXXXX";
const char *state = active != 0 ? "1\n" : "0\n";
int fd = mkstemp(temp_path);
if(fd < 0){
printf("failed to create HDMI state file: %d\n", errno);
return;
return false;
}
int failure = 0;
@@ -40,6 +40,7 @@ static void write_hdmi_state(uint8_t active)
unlink(temp_path);
printf("failed to publish HDMI state file: %d\n", failure);
}
return failure == 0;
}
uint8_t get_hdmi_version()
@@ -88,38 +89,45 @@ void Production_testing_patch(void)
system("sync");
}
void new_app_init(void)
static bool init_command(const char *command)
{
if(system(command) == 0) return true;
fprintf(stderr, "[kvms] migration failed: %s\n", command);
return false;
}
AppInitResult new_app_init(void)
{
// Update the necessary scripts
system("rm -f /boot/logo.jpeg");
system("cp -f /kvmapp/system/update-nanokvm.py /etc/kvm/");
if(!init_command("mkdir -p /etc/kvm /kvmapp/kvm")) return APP_INIT_FAILED;
if(!init_command("cp -f /kvmapp/system/update-nanokvm.py /etc/kvm/")) return APP_INIT_FAILED;
system("rm -f /etc/init.d/S02udisk");
system("cp -f /kvmapp/system/init.d/S00kmod /etc/init.d/");
system("cp -f /kvmapp/system/init.d/S01fs /etc/init.d/");
system("cp -f /kvmapp/system/init.d/S03usbdev /etc/init.d/");
system("cp -f /kvmapp/system/init.d/S15kvmhwd /etc/init.d/");
system("cp -f /kvmapp/system/init.d/S30eth /etc/init.d/");
system("cp -f /kvmapp/system/init.d/S50sshd /etc/init.d/");
if(!init_command("cp -f /kvmapp/system/init.d/S00kmod /etc/init.d/")) return APP_INIT_FAILED;
if(!init_command("cp -f /kvmapp/system/init.d/S01fs /etc/init.d/")) return APP_INIT_FAILED;
if(!init_command("cp -f /kvmapp/system/init.d/S03usbdev /etc/init.d/")) return APP_INIT_FAILED;
if(!init_command("cp -f /kvmapp/system/init.d/S15kvmhwd /etc/init.d/")) return APP_INIT_FAILED;
if(!init_command("cp -f /kvmapp/system/init.d/S30eth /etc/init.d/")) return APP_INIT_FAILED;
if(!init_command("cp -f /kvmapp/system/init.d/S50sshd /etc/init.d/")) return APP_INIT_FAILED;
if(kvm_wifi_exist()) {
system("cp -f /kvmapp/system/init.d/S30wifi /etc/init.d/");
if(!init_command("cp -f /kvmapp/system/init.d/S30wifi /etc/init.d/")) return APP_INIT_FAILED;
} else {
system("rm -f /etc/init.d/S30wifi");
}
// if exit /etc/init.d/S98tailscaled then cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/
if(access("/etc/init.d/S98tailscaled", F_OK) == 0){
system("cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/");
if(!init_command("cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/")) return APP_INIT_FAILED;
}
// rmmod soph_saradc
system("rmmod soph_saradc");
system("rm -f /mnt/system/ko/soph_saradc.ko");
// PCIe Patch
// system("cp /kvmapp/system/init.d/S95nanokvm /etc/init.d/");
if(access("/kvmapp/jpg_stream/dl_lib/libmaixcam_lib.so", F_OK) != 0){
system("cp -f /kvmapp/system/init.d/S95nanokvm /etc/init.d/");
}
// Keep an already running legacy shell on its original script inode.
if(!init_command("cp /kvmapp/system/init.d/S95nanokvm /etc/init.d/.S95nanokvm.new && "
"chmod 755 /etc/init.d/.S95nanokvm.new && "
"mv -f /etc/init.d/.S95nanokvm.new /etc/init.d/S95nanokvm")) return APP_INIT_FAILED;
// Remove unnecessary components to speed up boot time
system("rm -f /etc/init.d/S04backlight");
@@ -129,91 +137,45 @@ void new_app_init(void)
system("rm -f /etc/init.d/S99*");
// Add necessary configuration files for program execution
system("mkdir /kvmapp/kvm");
system("mkdir /etc/kvm");
system("echo 0 > /kvmapp/kvm/now_fps");
system("echo 30 > /kvmapp/kvm/fps");
system("echo 2000 > /kvmapp/kvm/qlty");
system("echo 720 > /kvmapp/kvm/res");
system("echo h264 > /kvmapp/kvm/type");
write_hdmi_state(0);
system("touch /etc/kvm/frame_detact");
if(!init_command("echo 0 > /kvmapp/kvm/now_fps && echo 30 > /kvmapp/kvm/fps && "
"echo 2000 > /kvmapp/kvm/qlty && echo h264 > /kvmapp/kvm/type && "
"touch /etc/kvm/frame_detact")) return APP_INIT_FAILED;
if(!write_hdmi_state(0)) return APP_INIT_FAILED;
// rm jpg_stream & kvm_stream
system("rm -rf /kvmapp/jpg_stream");
system("rm -f /kvmapp/kvm_system/kvm_stream"); // Cannot delete temporarily; the old production test script uses this file to determine if the download is complete
system("rm -f /kvmapp/kvm_new_app");
system("sync");
// system("/etc/init.d/S95nanokvm restart");
// update ko
FILE *fp;
uint8_t RW_Data_0[30];
uint8_t RW_Data_1[30];
fp = popen("md5sum /mnt/system/ko/soph_mipi_rx.ko | grep 086ed01749188975afaa40fb569374f8 | awk '{print $2}'", "r");
if ( NULL == fp )
{
pclose(fp);
// return;
}
fgets((char*)RW_Data_0, 10, fp);
pclose(fp);
fp = popen("md5sum /mnt/system/ko/soph_mipi_rx.ko | grep 69be7eeded3777f750480a5dd5a1aa26 | awk '{print $2}'", "r");
if ( NULL == fp )
{
pclose(fp);
// return;
}
fgets((char*)RW_Data_1, 10, fp);
pclose(fp);
FILE *fp = popen("md5sum /mnt/system/ko/soph_mipi_rx.ko", "r");
if(!fp) return APP_INIT_FAILED;
char digest[33] = {};
bool digest_read = fscanf(fp, "%32s", digest) == 1;
int digest_status = pclose(fp);
if(!digest_read || digest_status != 0) return APP_INIT_FAILED;
int8_t hdmi_ver = -1;
if(access("/etc/kvm/hdmi_version", F_OK) == 0){
uint8_t RW_Data[2];
FILE *fp = fopen("/etc/kvm/hdmi_version", "r");
fread(RW_Data, sizeof(char), 2, fp);
if(!fp) return APP_INIT_FAILED;
int version = fgetc(fp);
fclose(fp);
if(RW_Data[0] == 'c') hdmi_ver = 1;
else if(RW_Data[0] == 'u') hdmi_ver = 2;
else if(RW_Data[0] == 'd') hdmi_ver = 2;
if(version == EOF) return APP_INIT_FAILED;
if(version == 'c') hdmi_ver = 1;
else if(version == 'u' || version == 'd') hdmi_ver = 2;
}
// system("/etc/init.d/S03usbdev stop_start");
create_temp_watchdog();
if(hdmi_ver == 2){
if(RW_Data_1[0] != '/'){
system("cp /kvmapp/system/ko/soph_mipi_rx.ko /mnt/system/ko/soph_mipi_rx.ko");
system("sync");
system("reboot");
} else {
system("sync");
system("/etc/init.d/S15kvmhwd start");
system("/etc/init.d/S95nanokvm restart");
}
} else {
if((RW_Data_0[0] != '/') && (RW_Data_1[0] != '/')){
system("cp /kvmapp/system/ko/soph_mipi_rx.ko /mnt/system/ko/soph_mipi_rx.ko");
system("sync");
system("reboot");
} else {
system("sync");
system("/etc/init.d/S15kvmhwd start");
system("/etc/init.d/S95nanokvm restart");
}
bool current_driver = strcmp(digest, "69be7eeded3777f750480a5dd5a1aa26") == 0 ||
(hdmi_ver != 2 && strcmp(digest, "086ed01749188975afaa40fb569374f8") == 0);
if(!current_driver){
if(!init_command("cp /kvmapp/system/ko/soph_mipi_rx.ko /mnt/system/ko/soph_mipi_rx.ko")) return APP_INIT_FAILED;
}
if(access("/root/old/kvm_new_img", F_OK) == 0){
system("rm -r /root/old");
}
system("sync");
system("killall NanoKVM-Server");
system("rm -r /tmp/server");
system("cp -r /kvmapp/server /tmp/");
system("/tmp/server/NanoKVM-Server &");
if(unlink("/kvmapp/kvm_new_app") != 0) return APP_INIT_FAILED;
sync();
return current_driver ? APP_INIT_OK : APP_INIT_REBOOT;
}
void build_complete_resolv(void)

View File

@@ -3,7 +3,8 @@
#include "config.h"
void new_img_init(void);
void new_app_init(void);
enum AppInitResult { APP_INIT_OK = 0, APP_INIT_FAILED = 1, APP_INIT_REBOOT = 2 };
AppInitResult new_app_init(void);
void Production_testing_patch(void);
#endif // SYSTEM_INIT_H_

View File

@@ -192,10 +192,15 @@ int main(int argc, char* argv[])
new_img_init();
}
// Execute only when it's a new app
const bool migrate_only = argc == 2 && strcmp(argv[1], "--migrate") == 0;
if(access("/kvmapp/kvm_new_app", F_OK) == 0){
new_app_init();
AppInitResult result = new_app_init();
if(migrate_only || result == APP_INIT_FAILED) return result;
// A legacy init script starts us without --migrate and can still be
// copying the server. Boot once into the newly installed init script.
return system("reboot") == 0 ? 0 : 1;
}
if(migrate_only) return APP_INIT_OK;
// only for production testing
if (access("/tmp/S49kvmtest", F_OK) == 0){

View File

@@ -1,61 +1,23 @@
#include "kvm_vision.h"
#include "maix_basic.hpp"
using namespace maix;
using namespace maix::sys;
// #define NOT_GET_IMG
int main(int argc, char* argv[])
{
uint64_t __attribute__((unused)) start_time;
// Catch SIGINT signal(e.g. Ctrl + C), and set exit flag to true.
signal(SIGINT, [](int sig){ app::set_exit_flag(true);
log::info("========================\n");
});
kvmv_hdmi_control(0);
kvmv_hdmi_control(1);
kvmv_init(0);
set_h264_gop(30);
uint16_t get_fream_count = 0;
while(!app::need_exit()){
#ifdef NOT_GET_IMG
printf("NOT_GET_IMG ...\n");
time::sleep_ms(1000);
#else
uint8_t* p_kvmv_img_data;
uint32_t kvmv_img_data_size;
int ret;
printf("KVM-Vison Get Fream ...\n");
start_time = time::time_ms();
if(get_fream_count < 1){
get_fream_count ++;
ret = kvmv_read_img(1920, 1080, 1, 3000, &p_kvmv_img_data, &kvmv_img_data_size);
} else if(get_fream_count >= 1 && get_fream_count < 2){
get_fream_count ++;
ret = kvmv_read_img(0, 0, 0, 60, &p_kvmv_img_data, &kvmv_img_data_size);
} else {
get_fream_count = 0;
}
// printf("kvmv_read_img(): %d \r\n", (int)(time::time_ms() - start_time));
// ret = kvmv_read_img(1920, 1080, 1, 3000, &p_kvmv_img_data, &kvmv_img_data_size);
// ret = kvmv_read_img(1920, 1080, 0, 60, &p_kvmv_img_data, &kvmv_img_data_size);
printf("kvmv_read_img ret = %d\n", ret);
// send...
// if(ret >= 0){
free_kvmv_data(&p_kvmv_img_data);
// }
#endif
}
kvmv_deinit();
}
#include "kvm_vision.h"
#include "maix_basic.hpp"
using namespace maix;
int main()
{
signal(SIGINT, [](int) { app::set_exit_flag(true); });
kvmv_hdmi_control(1);
kvmv_init(0);
set_h264_gop(30);
uint8_t codec = 0;
while (!app::need_exit()) {
kvmv_frame_t frame = {};
int result = kvmv_read_img(1920, 1080, codec, codec ? 3000 : 60, 60, &frame);
printf("codec=%u result=%d size=%u pts=%llu\n", codec, result, frame.size,
static_cast<unsigned long long>(frame.timestamp));
kvmv_release_frame(&frame);
codec ^= 1;
}
kvmv_deinit();
}

View File

@@ -15,6 +15,7 @@ src
├── pages // web pages
│ ├── auth // login and password
│ ├── desktop // remote desktop
│ ├── storage // virtual media and the files inside them
│ └── terminal // web terminal
├── router.tsx // routers
└── types // types

View File

@@ -17,7 +17,6 @@
},
"dependencies": {
"@ant-design/icons": "^5.6.1",
"@radix-ui/react-scroll-area": "^1.2.10",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",

53
web/pnpm-lock.yaml generated
View File

@@ -11,9 +11,6 @@ importers:
'@ant-design/icons':
specifier: ^5.6.1
version: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-scroll-area':
specifier: ^1.2.10
version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@xterm/addon-attach':
specifier: ^0.11.0
version: 0.11.0(@xterm/xterm@5.5.0)
@@ -650,9 +647,6 @@ packages:
'@oxc-project/types@0.142.0':
resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
@@ -687,15 +681,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-direction@1.1.1':
resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dismissable-layer@1.1.11':
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
peerDependencies:
@@ -779,19 +764,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-scroll-area@1.2.10':
resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
@@ -3947,8 +3919,6 @@ snapshots:
'@oxc-project/types@0.142.0': {}
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.28)(react@18.3.1)':
@@ -3985,12 +3955,6 @@ snapshots:
'@types/react': 18.3.28
'@types/react-dom': 18.3.7(@types/react@18.3.28)
'@radix-ui/react-direction@1.1.1(@types/react@18.3.28)(react@18.3.1)':
dependencies:
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.28
'@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -4057,23 +4021,6 @@ snapshots:
'@types/react': 18.3.28
'@types/react-dom': 18.3.7(@types/react@18.3.28)
'@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.28
'@types/react-dom': 18.3.7(@types/react@18.3.28)
'@radix-ui/react-slot@1.2.3(@types/react@18.3.28)(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)

View File

@@ -2,16 +2,52 @@ import { http } from '@/lib/http';
export type UserRole = 'admin' | 'user';
export type Permission =
| 'console.view'
| 'console.control'
| 'power'
| 'medium.attach'
| 'library.read'
| 'library.write'
| 'terminal'
| 'device'
| 'network'
| 'update'
| 'mcp'
| 'tailscale'
| 'ai';
// Group names the party a grant reaches: the console this account watches, the
// machine behind it, the media library on this device, the device itself, or
// the services that reach it on their own.
export type PermissionGroup = 'console' | 'target' | 'library' | 'host' | 'services';
// Risk states how far a grant reaches. There are two levels: a plain grant, and
// the grants that amount to full device control, which the editor marks and
// names before the grants are saved.
export type PermissionRisk = 'info' | 'warn';
// PermissionDescriptor is one catalog entry as the server serves it. The editor
// renders the catalog instead of keeping its own copy of the model, so labels,
// groups and implied grants cannot drift from what the server enforces.
export type PermissionDescriptor = {
id: Permission;
group: PermissionGroup;
risk: PermissionRisk;
implies?: Permission[];
};
export type Account = {
username: string;
role: UserRole;
// The effective grants: the stored set plus everything it implies.
permissions: Permission[];
};
export type User = Account & {
enabled: boolean;
systemAccount?: boolean;
};
export function login(username: string, password: string) {
const data = {
username,
@@ -40,11 +76,25 @@ export function getUsers() {
return http.get('/api/auth/users');
}
export function createUser(username: string, password: string, role: UserRole) {
return http.post('/api/auth/users', { username, password, role });
export function getPermissions() {
return http.get('/api/auth/permissions');
}
export function updateUser(username: string, data: Partial<Pick<User, 'role' | 'enabled'>>) {
export function createUser(
username: string,
password: string,
role: UserRole,
permissions?: Permission[]
) {
return http.post('/api/auth/users', { username, password, role, permissions });
}
// rotateMcpKey asks the server to regenerate the device-wide MCP API key when
// the change takes the mcp grant away; without it the key is kept.
export function updateUser(
username: string,
data: Partial<Pick<User, 'role' | 'enabled' | 'permissions'>> & { rotateMcpKey?: boolean }
) {
return http.request({
method: 'put',
url: `/api/auth/users/${encodeURIComponent(username)}`,
@@ -52,8 +102,13 @@ export function updateUser(username: string, data: Partial<Pick<User, 'role' | '
});
}
export function deleteUser(username: string) {
return http.delete(`/api/auth/users/${encodeURIComponent(username)}`);
export function deleteUser(username: string, rotateMcpKey = false) {
// DELETE carries no body the server reads, so the choice goes in the query.
return http.request({
method: 'delete',
url: `/api/auth/users/${encodeURIComponent(username)}`,
params: rotateMcpKey ? { rotateMcpKey: true } : undefined
});
}
export function resetUserPassword(username: string, password: string) {

Some files were not shown because too many files have changed in this diff Show More