Compare commits

..

25 Commits
2.5.0 ... 2.5.3

Author SHA1 Message Date
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
watermeko
d0ff328db8 fix(web): import react-simple-keyboard by its named export
`react-simple-keyboard` ships only a UMD/CJS bundle whose `module.exports`
is `{ KeyboardReact, default: KeyboardReact }` with `__esModule: true`.

Vite 8 applies Node-compatible CJS interop for ESM importers, so the
default import resolved to the whole `module.exports` object instead of
the component. Rendering it made React throw "Minified React error #130"
(`got: object`) as soon as the on-screen keyboard was opened.

Import the `KeyboardReact` named export, which is the same component,
and keep `KeyboardButtonTheme` as a type-only import.

Fixes #936
2026-09-29 10:15:53 +08:00
watermeko
ff45b46c49 feat(storage): mount images read-only and hand /data to one writer (#937)
Only one side may write the mounted image or the /data partition at a time,
and uploaded images are validated before they are stored.

Image mounts:

* Serve every mounted image read-only, in CD-ROM and mass storage mode alike:
  detach the LUN first and set the read-only flag before the image is
  attached, so the target machine can neither write to nor format it. The
  flags were previously only written for CD-ROM mounts, so a mass storage
  mount inherited ro=0 and the image stayed writable from the host.
* Report the virtual disk as on only while the LUN really carries the /data
  partition. With an image mounted the switch used to stay active but
  unchangeable, and enabling the disk is now a confirmed action instead of a
  rejected one: it cancels the mounted image, and the confirm also warns that
  the image download is interrupted because /data becomes read-only here.
* Ask before mounting: the image list warns that the virtual disk is released
  for the image, or offers to enable it first when it is off, instead of
  dropping the /data share silently. The new strings are translated for every
  locale.
* Name the mounted medium and the USB device after the image file, cut to the
  16 bytes of the SCSI inquiry product field and the 126 bytes of the USB
  product string, and restore the previous names on unmount.
* Reset the LUN flags on every gadget restart. They survived a restart, so
  the shared partition came back to the host read-only, or as an optical
  drive, after an image had been mounted.

The /data handover:

* Mount /data read-only at boot whenever the virtual disk serves the raw
  partition as a writable disk, so the handover holds from the first boot on.
* Add SetDataWritable(), which remounts /data and verifies the new state.
  Enabling the virtual disk makes /data read-only before the host can write
  it, and refuses to expose the disk when the remount fails instead of taking
  the risk. Unmounting an image, which hands the raw partition back to the
  host, does the same, while mounting an image switches /data back to
  writable because a served image is read-only for the host.
* Cancel a running image transfer before the handover and wait for it to
  stop, and report the state in the image list, where mounting still works
  while deleting and uploading do not. The delete action is disabled with a
  hint, and the downloader offers to turn the virtual disk off.

Image uploads:

* Validate the volume descriptors while streaming, so an unusable image is
  rejected in seconds instead of after the whole transfer: accept ISO 9660 and
  UDF volume descriptors (CD001, BEA01) as well as raw disk images (GPT, APFS,
  HFS+, MBR), and reject anything else. Require the image to fit in /data with
  a 128 MiB reserve before any of it is received, log the underlying errors on
  the rejection paths, and keep the temporary file, the progress ticker and the
  close handling in defers.

Add unit tests for the upload header check, the free space preflight and the
image name truncation.
2026-09-24 09:17:24 +08:00
Guoguo
bd070b2f5c docs: add 2.5.1 changelog entries (#929) 2026-09-09 16:38:14 +08:00
watermeko
5c0bef019f fix(terminal): start a root login shell
Launch Web terminal sessions in /root as login shells so they load the root profile and display the configured prompt.
2026-09-04 17:25:51 +08:00
watermeko
283c1390e5 feat(shell): add informative SSH prompt
Create a default root profile when one does not already exist. Show the user, hostname, and working directory while preserving custom profiles.
2026-09-04 17:25:51 +08:00
watermeko
7f95fe9bbd fix(stream): isolate H264 subscribers
Keep slow WebRTC consumers from blocking the shared H264 source by dropping buffered frames until the next keyframe. Restore the WebRTC NACK and RTCP report interceptors while retaining a safe RTP MTU, and enable linker --as-needed for libkvm to remove unused OpenCV dependencies.
2026-09-01 15:34:20 +08:00
watermeko
a9e1ef2459 perf(stream): share H264 capture frames
Route Direct and WebRTC consumers through one H264 capture source to avoid duplicate VENC reads. Overlap WebRTC capture with ordered RTP/SRTP writes and packetize each access unit once for all WebRTC clients.

Add libopencv_video.so.409 into dl_lib.
2026-09-01 15:34:20 +08:00
watermeko
2ba45a2147 feat(web): enhance configurable input regions
Make input-region calibration work across MJPEG and H.264 video modes, including robust four-sided black-border detection and initial-selection edge snapping. Persist multiple reusable manual regions, keep manual and original-resolution choices mutually exclusive, migrate legacy settings, and localize the new controls for every supported language.
2026-08-25 09:36:13 +08:00
watermeko
9e6d280826 feat(web): add configurable mouse input regions
Add device-level input-region APIs for reading, validating, and atomically persisting absolute mouse calibration settings, including automatic, manual, and disabled modes.

Introduce a shared screen viewport that keeps the rendered media geometry and configured input region aligned across MJPEG, direct H.264, and WebRTC streams. Add automatic black-border detection and a manual selection overlay with dragging, resizing, magnified preview, keyboard locking, and cancellation support.

Provide original-resolution presets so manual calibration can be recalculated for common aspect ratios, expose the controls from the desktop mouse menu, close menus before entering selection mode, and preserve active file transfers when the download menu closes.

Add localized control-region labels and messages for all supported locales, and update direct-frame rendering so media dimensions are reported after the canvas has been drawn.
2026-08-25 09:36:13 +08:00
watermeko
527caeff6a fix(web): correct mouse coordinates for direct h264 frames 2026-08-25 09:36:13 +08:00
肆月
71ab9127dd feat: add secure multi-user support (#876) 2026-08-20 14:29:56 +08:00
肆月
d382f0621d fix: report and retry WebRTC connection failures (#866) 2026-08-13 14:56:01 +08:00
watermeko
9415afb359 fix(mouse): improve touch interaction and screen fitting (#857)
- Add touch gestures for relative mouse mode, including tap, drag, long press, double-tap drag, and two-finger scrolling.

- Improve absolute mouse touch handling and prevent black letterbox areas from generating remote mouse events.

- Fit H.264 and MJPEG screens with contain scaling and disable browser touch handling on the screen elements.

- Move golang.org/x/sys to the direct dependency list.
2026-08-11 09:47:04 +08:00
肆月
e5f6dfabaa fix(ota): isolate updates in persistent workspaces (#863)
* fix(ota): isolate updates in persistent workspaces

Stage online and offline update archives under /root/.kvmcache/nanokvm-update-* and validate storage, manifests, and archive contents before changing the installed application.

* fix(ota): harden storage safety and release gates

Preserve the last rollback backup when update storage is insufficient, and verify the actual application mount point before installation.

Move the shared transfer sentinel from /tmp to /run, enforce device package limits in release verification, and run that verification in package CI.
2026-08-10 14:49:29 +08:00
watermeko
757e0f61c5 feature: provide HDMI status in /api/vm/hdmi (#859)
* feature: provide HDMI status in  /api/vm/hdmi

* fix: return false when HDMI idle in  /api/vm/hdmi
2026-08-07 09:43:22 +08:00
肆月
761b02ed7e feat(web): upgrade build tooling to Vite 8 (#853)
Upgrade Vite to 8.2.0 and @vitejs/plugin-react to 6.0.5.

Use Vite's native tsconfig path resolver and remove vite-tsconfig-paths.

Verified with pnpm build and pnpm lint (existing warnings only).
2026-08-06 14:25:55 +08:00
Guoguo
562ea8277c feat: add dev container for full-stack development (#861)
Layer the frontend toolchain on top of the existing release builder
image so a single container covers Go, C support-layer and web
development, with the builder image remaining the single source of
truth for the cross toolchain, Go and MaixCDK.

- Mount the workspace at /home/build/NanoKVM, where the Makefile and
  support/sg2002/build expect it, and resync MaixCDK components via
  update_lib on create so C builds never use stale sources
- Install Node from official dist tarballs (integrity-checked, pinned
  to the major used by CI), with NODE_DIST_MIRROR / NPM_REGISTRY /
  BASE_IMAGE build args for restrictive or mirrored corporate networks
- Export the MaixCDK virtualenv via remoteEnv so non-interactive
  processes (tasks, extensions, exec) get it too; make
  updateRemoteUserUID explicit since the published builder image bakes
  the CI runner's uid
- Run pnpm non-interactively during post-create (no TTY) and give the
  skeleton-less home a standard ~/.profile -> ~/.bashrc chain
- Make the web toolchain convention explicit via package.json engines
  (Node >= 22, pnpm >= 11, matching CI) and document it in web/README
- Document the dev container in the README, point server/README at the
  container flow that works on any host OS, and ignore .pnpm-store/
2026-08-06 11:00:40 +08:00
Guoguo
7abec3cbad docs: add 2.5.0 changelog entries (#860) 2026-08-06 10:18:29 +08:00
Guoguo
8fc31553f7 ci: prefix GitHub Release titles with nanokvm (#856) 2026-08-05 11:52:22 +08:00
watermeko
9e50040713 fix(ota): keep update lock through service restart 2026-08-05 11:50:39 +08:00
watermeko
0f8ef20fb5 feat(ota): verify offline update checksums 2026-08-05 11:50:39 +08:00
watermeko
7cda3b2920 feat(ota): add custom update server support
+ Add persistent custom update server configuration, authenticated URL support, manifest validation, and the settings UI.

+ Keep application updates rebootless by coordinating video shutdown, migration-time service startup, and online/offline service restarts.

+ Localize the custom update server workflow across all supported languages.
2026-08-05 11:50:39 +08:00
223 changed files with 24918 additions and 3703 deletions

52
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,52 @@
# Dev container image for VS Code / the devcontainer CLI.
#
# The release builder image (docker/Dockerfile) stays the single source of
# truth for the RISC-V cross toolchain, Go and MaixCDK. This layer only adds
# the frontend toolchain on top: the builder image deliberately ships without
# Node (`make web` runs on the host), but an IDE container should cover web
# development as well.
#
# BASE_IMAGE can be overridden to a locally built builder image, e.g. the
# output of `make builder-image` (nanokvm-builder-local-<uid>-<gid>).
ARG BASE_IMAGE=ghcr.io/sipeed/nanokvm-builder:latest
FROM ${BASE_IMAGE}
USER root
# Node major and pnpm major track .github/workflows/package.yml ("Set up
# Node" / "Set up pnpm") and web/package.json "engines" -- keep all of them
# in sync when CI bumps either.
ARG NODE_MAJOR=22
ARG PNPM_VERSION=11
# Both can be overridden behind restrictive or mirrored corporate networks
# via devcontainer.json, e.g.:
# "build": {"args": {
# "NODE_DIST_MIRROR": "https://registry.npmmirror.com/-/binary/node",
# "NPM_REGISTRY": "https://registry.npmmirror.com"
# }}
ARG NODE_DIST_MIRROR=https://nodejs.org/dist
ARG NPM_REGISTRY=https://registry.npmjs.org
# Install the latest Node of the pinned major from official dist tarballs;
# tracking the latest patch release is intentional (rebuild the container to
# pick up updates). The SHA-256 check guards download integrity -- it shares
# its origin with the tarball, so it is not an authenticity proof.
RUN set -eux; \
apt-get update; \
apt-get install -y ca-certificates curl xz-utils; \
rm -rf /var/lib/apt/lists/*; \
case "$(uname -m)" in \
x86_64) node_arch=x64 ;; \
aarch64) node_arch=arm64 ;; \
*) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;; \
esac; \
curl -fsSL "${NODE_DIST_MIRROR}/latest-v${NODE_MAJOR}.x/SHASUMS256.txt" \
-o /tmp/SHASUMS256.txt; \
tarball="$(grep -o "node-v[0-9.]*-linux-${node_arch}\.tar\.xz" /tmp/SHASUMS256.txt | head -n1)"; \
curl -fsSL "${NODE_DIST_MIRROR}/latest-v${NODE_MAJOR}.x/${tarball}" -o "/tmp/${tarball}"; \
cd /tmp && grep " ${tarball}\$" SHASUMS256.txt | sha256sum -c -; \
tar -xJf "/tmp/${tarball}" -C /usr/local --strip-components=1 --no-same-owner; \
rm -f "/tmp/${tarball}" /tmp/SHASUMS256.txt; \
node --version && npm --version
RUN npm install --global --registry "${NPM_REGISTRY}" "pnpm@${PNPM_VERSION}" \
&& pnpm --version

View File

@@ -0,0 +1,49 @@
// Dev container for NanoKVM: Go cross-compilation, MaixCDK C support layer
// and web frontend in a single container. See "Development" in README.md.
{
"name": "NanoKVM",
"build": {
"dockerfile": "Dockerfile"
},
// The Makefile and support/sg2002/build expect the repository at
// ~/NanoKVM of the baked-in "build" user -- mount it exactly there.
"workspaceMount": "source=${localWorkspaceFolder},target=/home/build/NanoKVM,type=bind,consistency=cached",
"workspaceFolder": "/home/build/NanoKVM",
"remoteUser": "build",
// The published builder image bakes in the CI runner's uid (currently
// 1001); on Linux hosts this remaps "build" to the local uid so the bind
// mount stays writable. The remap chowns /home/build once, which makes
// the first container creation noticeably slower.
"updateRemoteUserUID": true,
// Equivalent of `. ~/MaixCDK/bin/activate` for every process spawned in
// the container, interactive or not (tasks, extensions, exec).
"remoteEnv": {
"VIRTUAL_ENV": "/home/build/MaixCDK",
"PATH": "/home/build/MaixCDK/bin:${containerEnv:PATH}"
},
"forwardPorts": [3001],
"portsAttributes": {
"3001": {
"label": "web dev server (vite)"
}
},
"postCreateCommand": ".devcontainer/post-create.sh",
// This configuration itself mounts nothing from the host (VS Code may
// still forward your SSH agent and git credentials by default). To use
// git over SSH or reach a NanoKVM device from inside the container,
// uncomment the mount below.
// "mounts": [
// "source=${localEnv:HOME}/.ssh,target=/home/build/.ssh,type=bind,readonly"
// ],
"customizations": {
"vscode": {
"extensions": [
"golang.go",
"ms-vscode.cpptools",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss"
]
}
}
}

37
.devcontainer/post-create.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Runs once inside the dev container after it is created (see
# .devcontainer/devcontainer.json). Keeps the container aligned with the
# CLI flow in the Makefile and scripts/build-in-container.sh.
set -euo pipefail
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
# The MaixCDK components baked into the image can be older than this
# checkout; resync them so C builds never use stale sources (same reason
# scripts/build-in-container.sh runs update_lib first).
"$repo_root/support/sg2002/build" update_lib
# Frontend dependencies. postCreate has no TTY, and CI=true lets pnpm make
# its non-interactive choices -- e.g. purging a node_modules tree that was
# installed from the host OS through the bind mount.
(cd "$repo_root/web" && CI=true pnpm install --frozen-lockfile)
# Start every interactive shell with the MaixCDK virtualenv active,
# mirroring `make shell`.
activate_line='. "$HOME/MaixCDK/bin/activate"'
if ! grep -qxF "$activate_line" "$HOME/.bashrc" 2>/dev/null; then
printf '\n# MaixCDK virtualenv (added by .devcontainer/post-create.sh)\n%s\n' \
"$activate_line" >>"$HOME/.bashrc"
fi
# The build user's home has no shell skeleton files (useradd ran with
# --no-create-home), so login shells would skip ~/.bashrc; give them the
# standard Debian chain.
if [ ! -f "$HOME/.profile" ]; then
cat >"$HOME/.profile" <<'EOF'
# ~/.profile (added by .devcontainer/post-create.sh)
if [ -n "$BASH_VERSION" ] && [ -f "$HOME/.bashrc" ]; then
. "$HOME/.bashrc"
fi
EOF
fi

View File

@@ -251,6 +251,11 @@ jobs:
echo "sha512_base64=${SHA512_BASE64}"
} > build/release/BUILD_INFO.txt
- name: Verify release assets
run: |
./scripts/verify-release-assets.sh \
build/release "${{ steps.version.outputs.version }}"
- name: Summary
env:
VERSION: ${{ steps.version.outputs.version }}

View File

@@ -147,7 +147,7 @@ jobs:
./scripts/verify-release-tag.sh "$TAG" "$SOURCE_SHA" >/dev/null
gh release create "$TAG" \
--verify-tag \
--title "$TAG" \
--title "nanokvm@${TAG}" \
--notes "$NOTES" \
"${STATE_ARGS[@]}" \
"$TARBALL" build/release/latest.json build/release/sha256.txt

6
.gitignore vendored
View File

@@ -5,6 +5,10 @@ web/node_modules
web/dist
web/web
# pnpm keeps its content-addressable store next to the project when the
# home directory is on a different filesystem (e.g. inside the dev container)
.pnpm-store/
.DS_Store
*.local
*.suo
@@ -32,3 +36,5 @@ kvmapp/kvm_system/kvm_system
# Release packages assembled by scripts/package.sh
/build/
kvmapp/server
kvmapp/version

View File

@@ -1,3 +1,149 @@
## 2.5.3 (2026-11-03)
### Features
* Replaced the desktop image menu and the download page with a storage page that lists the media the emulated USB disk can carry, mounts and ejects them, and browses the files inside a medium over the same gadget, so the two machines exchange files without going through the terminal
* Added uploads, downloads and deletes to the storage page, with search, sorting, folder deletion behind a confirmation, one transfer display for image and file transfers, cancellable uploads, a free space preflight and a checksum mismatch report
* Browsed only media whose filesystem this device can read, so an ISO 9660 image is still served to the target machine but cannot be listed here, and limited CD-ROM mode to ISO 9660 and UDF images
* Made the shared folder follow the target machine: while it owns the data partition the listing refreshes on a timer after dropping the dentry and inode caches, so a file the target machine wrote shows up within a second of its own writeback
* Added a fine-grained permission system on top of the `admin` and `user` roles: administrators keep implicit full access, while every other account receives an explicit set of grants covering the console, power, media, terminal, device, network, updates, MCP, Tailscale and AI features, with the groups, risk marks and implied grants shown in a permission editor; mounting a medium needs the virtual disk, and the storage page names the account that has to enable it when the current account cannot
* Added a view-only console session for an account that may watch but not send input: the pointer and keyboard capture, the mouse menu and the input region editor follow the grant, pages, menus, settings tabs and storage actions render only what the account is allowed to use, and an account without console access lands on a page offering the sections its grants open
* Changed permission handling so an open session picks up the new grants without ending the login: a change that invalidates a cached capability drops the long-lived connection with a dedicated close code so the page reconnects, and only disabling an account ends the login
* Added the choice of regenerating the device-wide MCP API key when an account loses the MCP grant, instead of rotating the key silently and disconnecting every MCP client
* Kept the pre-2.6 image endpoints working as a compatibility layer over the new media API, so existing clients and scripts keep working
* Treated UDF images, not only ISO 9660 ones, as optical media, and accepted uploads by extension (`.iso`/`.img`), leaving an unusable image to fail when it is mounted instead of while it is stored
### Bug Fixes
* Fixed HID input being lost after a medium was mounted or the gadget was otherwise reset, by reopening the HID devices with a retry instead of a single attempt that could fail while the new nodes were still coming up
* Fixed `/data` staying read-only after a failed gadget script or a network switch
* Fixed the settings menu icon sitting a few pixels below the other menu icons, and centered the dialog tab icons in their box
* Fixed Chinese and Japanese punctuation being drawn by a Latin face in tooltips and dialogs, which pushed the ideographic comma and full stop above the baseline
### UI Improvements
* Gave the shared folder, ISO and IMG entries their own glyphs and picked file icons from the extension, so a listing can be read at a glance
* Listed administrators above regular accounts and gave their rows a lighter card
* Moved the PicoClaw chat composer to an antd text area and replaced the Radix scroll area with the native scroll container, so the settings dialog and the shortcut lists scroll like the rest of the app
### Localization
* Translated every string the storage page, the permission editor, the view-only console and the MCP key rotation add for all 24 supported locales
* Removed the keys the retired image menu and download page left behind
### Security
* Mounted images are opened through loop devices that refuse symlinks, and files inside a medium are opened without following links
### Chores
* Removed the desktop image menu, the download page and their web client code
* Dropped the `@radix-ui/react-scroll-area` dependency
* Documented the permission catalog, the split between mounting media and writing the library, and the grants that reach the whole device in `server/README.md` and `server/README_ZH.md`
## 2.5.2 (2026-09-29)
### Features
* Added a one-writer policy for the emulated USB disk and the `/data` partition: `/data` is mounted read-only from boot while the virtual disk serves the raw partition to the target machine, enabling the disk remounts it read-only and verifies the state before the target machine can write it, refusing to expose the disk when the remount fails, and releasing a medium hands the raw partition back to the target machine the same way
* Added confirmation before a mounted medium and the virtual disk take the shared partition from each other, including a warning that a running image transfer is cancelled and that `/data` becomes read-only on this device
* Added cancellation of a running image transfer before the partition handover, showed the transfer state in the image list and disabled the actions that cannot run while a medium is mounted, with a hint explaining why
* Added names for the mounted medium and the emulated USB device taken from the image file, truncated to the SCSI inquiry product field and the USB product string and restored on unmount
* Added streaming validation of uploaded images: ISO 9660 and UDF volume descriptors and raw disk images (GPT, APFS, HFS+, MBR) are accepted and anything else is rejected in seconds instead of after the whole transfer, with a free space check that keeps a 128 MiB reserve before any byte is received
### Bug Fixes
* Fixed mass storage mounts staying writable from the target machine: every mounted image is now served read-only, with the flag set before the LUN is attached, so the target machine can neither write to nor format it
* Fixed the LUN flags surviving a gadget restart, which brought the shared partition back to the target machine read-only or as an optical drive after a medium had been mounted
* Fixed the virtual disk switch staying on but unchangeable while a medium was mounted; the disk now reports itself on only while the LUN really carries the `/data` partition
* Fixed the on-screen keyboard crashing the page with "Minified React error #130" as soon as it was opened, by importing the `react-simple-keyboard` component through its named export, which the CommonJS interop under Vite 8 no longer resolves from a default import
### Localization
* Translated the new virtual disk and image mounting strings for every locale
### Chores
* Added unit tests for the upload header check, the free space preflight and the image name truncation
## 2.5.1 (2026-09-09)
### Features
* Added device-level multi-user support with `admin` and `user` roles and a user management page under Settings, separating administrative areas (terminal, scripts, storage, network, updates, MCP, PicoClaw and Tailscale) from the video, keyboard, mouse, paste, wake-on-LAN and power controls every account keeps
* Moved sessions to HttpOnly cookies with a per-user token version, so changing a password or disabling an account immediately ends that user's open web and WebSocket sessions
* Added support for a custom update server, so the application can be updated from your own mirror, with credentials in the URL, manifest validation and a settings page for configuring it
* Added SHA-256 verification of offline update packages before they are installed
* Added configurable mouse input regions for absolute mode, with automatic black-border detection, manual selection of the controllable area on screen, and original-resolution presets, so the pointer stays aligned when the target machine outputs a letterboxed or non-16:9 signal
* Added touch gestures to relative mouse mode, including tap, drag, long press, double-tap drag and two-finger scroll, so relative mode is now usable from a phone or tablet
* Added a `signal` field to `/api/vm/hdmi` reporting whether an HDMI source is actually present, not just whether capture is enabled
* Added a default root shell profile showing the user, host and working directory in the prompt over SSH and the Web terminal
### Bug Fixes
* Fixed mouse coordinates being offset in Direct H.264 mode when the target machine pads the picture with black bars
* Fixed touch input in absolute mode, and stopped taps on the black letterbox area from being sent to the target machine
* Fixed the video area not fitting the window, by scaling H.264 and MJPEG playback to contain and letting the page handle touch instead of the browser
* Fixed WebRTC H.264 failures leaving a silent black screen; signaling, negotiation, ICE and playback failures now report what went wrong and offer a retry
* Fixed a slow WebRTC viewer stalling the shared H.264 source for everyone else, by dropping its buffered frames until the next key frame
* Fixed application updates running from a temporary directory that could be too small or shared with another update; online and offline updates are now staged in isolated persistent workspaces with archive, manifest and free-space checks, and the last rollback backup is kept when space runs short
* Fixed the update lock being released before the server finished restarting, which allowed a second update to start and be killed mid-install
* Fixed Web terminal sessions not loading the root profile, by starting them as login shells in `/root`
### Performance
* Routed Direct and WebRTC viewers through a single shared H.264 capture source instead of each reading the encoder separately
### Localization
* Synchronized translations of other languages according to English
### Chores
* Upgraded the web build tooling to Vite 8 and switched to Vite's native tsconfig path resolution
* Added a dev container for full-stack development
* Prefixed GitHub Release titles with `nanokvm`
* Added release asset verification with per-device package size limits to the packaging workflow
## 2.5.0 (2026-08-04)
### Features
* Added an MCP service that lets trusted MCP clients capture screenshots and control the keyboard and mouse with an API key, including a settings page for enabling it and regenerating the key
* Added coordinated device control so MCP, PicoClaw and manual input hand ownership over to each other instead of writing HID input at the same time
* Added remote keyboard lock indicators that show the target machine's Num Lock, Caps Lock and Scroll Lock state
* Added an optional SHA-256 checksum and cancellation to remote image downloads
### Bug Fixes
* Fixed the USB network gadget getting a random host-side MAC on every bind, which made the attached PC register a new network adapter after each reboot; both the device and host MAC are now derived from the chip UID (thanks to [@BeaconCat](https://github.com/BeaconCat))
* Fixed OLED sleep durations above 255 seconds being truncated, so the 5 min, 10 min, 30 min and 1 hour options now behave as selected
* Fixed the mounted image API returning an error in HID-Only mode
* Fixed image downloads and application updates interfering with each other when running at the same time
* Fixed Direct H.264 playback falling behind when the decoder queue backed up, by resynchronizing at the next key frame and preferring hardware decoding
* Added a warning when Direct and WebRTC H.264 stream modes are used at the same time
* Made the video input state shared between processes and read it without shell pipelines, so HDMI status stays consistent between `NanoKVM-Server` and `kvm_system`
### Performance
* Reduced latency in 60 Hz mode with decode-driven flow control, GOP-aware bounded queues, and dedicated writer goroutines with write deadlines so a slow client no longer stalls the others
* Integrated zero-copy H.264 capture into the video pipeline instead of relying on a preload hook
* Stopped HDMI capture when no user is logged in or all viewers are idle, reducing power consumption
### UI Improvements
* Tidied the image download dialog layout and truncated long file names in the upload box
### Localization
* Synchronized translations of other languages according to English
### Chores
* Split release automation into separate package, tag and release workflows, and produced identifiable build artifacts with checksums for pull requests
* Allowed overriding the builder image, installed `patchelf` in the build environment, and fixed the exit codes of `support/sg2002/build`
* Fixed slow container builds caused by uid/gid mismatch and reused existing container users and groups
## 2.4.3 (2026-06-09)
### Features

View File

@@ -91,6 +91,18 @@ Start with the guide that matches the part of NanoKVM you want to work on:
> Backend compilation and runtime validation require the target toolchain or a NanoKVM device. See the module-specific guides above for the latest development workflow.
### Dev container (optional)
The repository ships a [Dev Container](https://containers.dev) setup (`.devcontainer/`) that layers the frontend toolchain (Node 22, pnpm 11) on top of the release builder image (RISC-V cross toolchain, Go, MaixCDK), so one container covers Go, C support-layer and web development. It is a convenience for IDE users; the `make` targets in the repository root remain the canonical CLI workflow.
- Requirements: Docker plus an editor or CLI with dev container support (e.g. the VS Code "Dev Containers" extension, or `devcontainer up`).
- On creation the container prepares itself automatically: `support/sg2002/build update_lib` (resyncs MaixCDK components with the checkout) and `pnpm install` for the frontend.
- Backend: run `server/build.sh` for a release-equivalent binary (BoringCrypto + `dl_lib` RPATH). `make app` builds a plain development binary without those, so prefer `server/build.sh` whenever the result should match a release.
- Support layer: `cd support/sg2002 && ./build kvm_system` (the MaixCDK virtualenv is part of the container environment).
- Frontend: `cd web && pnpm dev` (VS Code forwards port 3001 automatically; other clients may need to map it themselves); see [web/README.md](web/README.md) for pointing it at a device.
- The base image `ghcr.io/sipeed/nanokvm-builder:latest` is x86-64 only; on Apple Silicon it runs under emulation (works, but C builds are slow). You can also build the image locally first (`make builder-image`) and point the `BASE_IMAGE` build arg at it (e.g. via `build.args` in `devcontainer.json`). On Linux hosts the first container creation also takes a while: the container user is remapped to your uid, which re-chowns its home directory once.
- Hardware-dependent verification still requires a real NanoKVM device; the container covers compiling, linting and mock-mode web development only.
## 🔩 Hardware Platform (NanoKVM Cube/PCIe)
NanoKVM is based on Sipeed [LicheeRV Nano](https://wiki.sipeed.com/hardware/zh/lichee/RV_Nano/1_intro.html). You can find specifications, schematics, and dimensional drawings in the [download station](https://dl.sipeed.com/shareURL/LICHEE/LicheeRV_Nano).

View File

@@ -35,7 +35,14 @@ then
if [ -e /dev/mmcblk0p3 ]
then
mkdir -p /data
mount /dev/mmcblk0p3 /data
# The partition is the virtual disk. While it is shared as a writable
# disk the host is the only writer, so mount it read-only here.
if [ -e /boot/usb.disk0 ] && [ ! -e /boot/usb.disk0.ro ] && [ ! -s /boot/usb.disk0 ]
then
mount -o ro /dev/mmcblk0p3 /data
else
mount /dev/mmcblk0p3 /data
fi
fi
echo "OK"

View File

@@ -134,11 +134,15 @@ start_usb_dev(){
mkdir functions/mass_storage.disk0
ln -s functions/mass_storage.disk0 configs/c.1/
echo 1 > functions/mass_storage.disk0/lun.0/removable
# The LUN flags survive a restart of this script, so they are always set:
# a mounted image leaves them read-only and in CD-ROM mode.
if [ -e /boot/usb.disk0.ro ]
then
echo 1 > functions/mass_storage.disk0/lun.0/ro
echo 0 > functions/mass_storage.disk0/lun.0/cdrom
else
echo 0 > functions/mass_storage.disk0/lun.0/ro
fi
echo 0 > functions/mass_storage.disk0/lun.0/cdrom
echo "NanoKVM USB Mass Storage0520" > functions/mass_storage.disk0/lun.0/inquiry_string
disk=$(cat /boot/usb.disk0)
if [ -z "${disk}" ]

View File

@@ -1,9 +1,82 @@
#!/bin/sh
# nanokvm Rev3.1
wait_for_exit() {
process="$1"
timeout="$2"
elapsed=0
while pidof "$process" >/dev/null 2>&1; do
if [ "$elapsed" -ge "$timeout" ]; then
return 1
fi
sleep 1
elapsed=$((elapsed + 1))
done
return 0
}
stop_process() {
process="$1"
if ! pidof "$process" >/dev/null 2>&1; then
return
fi
killall -INT "$process" 2>/dev/null || true
if wait_for_exit "$process" 20; then
return
fi
killall -TERM "$process" 2>/dev/null || true
if wait_for_exit "$process" 5; then
return
fi
killall -KILL "$process" 2>/dev/null || true
wait_for_exit "$process" 2 || true
}
stop_services() {
# The server owns VI/VPSS. Let it release MMF before stopping kvm_system.
stop_process NanoKVM-Server
stop_process kvm_system
rm -rf /tmp/kvm_system /tmp/server
}
start_services() {
cp -r /kvmapp/kvm_system /tmp/
if [ -f /kvmapp/kvm_new_app ]; then
touch /tmp/.nanokvm_migrating
/tmp/kvm_system/kvm_system &
# The legacy migration invokes this init script recursively and starts
# the server itself. Wait for that single migration path to finish.
migration_wait=0
while [ "$migration_wait" -lt 30 ]; do
if [ ! -f /kvmapp/kvm_new_app ] && pidof NanoKVM-Server >/dev/null 2>&1; then
rm -f /tmp/.nanokvm_migrating
return
fi
sleep 1
migration_wait=$((migration_wait + 1))
done
rm -f /tmp/.nanokvm_migrating
else
/tmp/kvm_system/kvm_system &
fi
if ! pidof NanoKVM-Server >/dev/null 2>&1; then
rm -rf /tmp/server
cp -r /kvmapp/server /tmp/
/tmp/server/NanoKVM-Server &
fi
}
case "$1" in
start)
echo -n kvm > /boot/hostname.prefix
[ -e /root/.profile ] || printf '%s\n' 'export PS1="\u@\h:\w# "' > /root/.profile
# Copy sensor config if the file exists
[ -f /mnt/data/sensor_cfg.ini.LT ] && cp /mnt/data/sensor_cfg.ini.LT /mnt/data/sensor_cfg.ini
@@ -45,30 +118,20 @@ case "$1" in
fi
# Start services
cp -r /kvmapp/kvm_system /tmp/
/tmp/kvm_system/kvm_system &
cp -r /kvmapp/server /tmp/
/tmp/server/NanoKVM-Server &
start_services
;;
stop)
killall kvm_system
killall NanoKVM-Server
rm -r /tmp/kvm_system /tmp/server
stop_services
echo "OK"
;;
restart)
killall kvm_system
killall NanoKVM-Server
rm -r /tmp/kvm_system /tmp/server
cp -r /kvmapp/kvm_system /tmp/
/tmp/kvm_system/kvm_system &
cp -r /kvmapp/server /tmp/
/tmp/server/NanoKVM-Server &
if [ -f /tmp/.nanokvm_migrating ]; then
exit 0
fi
stop_services
start_services
sync
echo "OK"
@@ -78,4 +141,4 @@ case "$1" in
echo "Usage: $0 {start|stop|restart}"
exit 1
;;
esac
esac

View File

@@ -16,9 +16,9 @@ over the air: `nanokvm_<version>.tar.gz` plus its `latest.json` manifest.
The format is not arbitrary — it is fixed by the on-device updater in
`server/service/application/`:
- **One root directory.** `install.go` untars the package and moves the single
top-level directory over `/kvmapp`, so the tarball must contain exactly
`nanokvm_<version>/`.
- **One safe root directory.** The updater scans before extracting and accepts
only directories and regular files under `nanokvm_<version>/`; links,
special files, duplicate paths, and path traversal are rejected.
- **`name` is the file name.** `version.go` builds the download URL as
`<base>/<name>`, where `<base>` is `https://cdn.sipeed.com/nanokvm` (or
`.../preview` when `/etc/kvm/preview_updates` exists).
@@ -27,9 +27,13 @@ The format is not arbitrary — it is fixed by the on-device updater in
- **`/kvmapp/version`** is what the device reports as its installed version, so
it must match the `version` field.
`size` is parsed into `Latest.Size` but never read anywhere, so nothing on the
device depends on its units. Published manifests have carried a kilobyte-ish
value; `package.sh` writes the exact byte count instead.
New releases use manifest v2. `size` remains the exact compressed byte count
for older clients, while v2 clients use `size_bytes` and
`unpacked_size_bytes` for storage preflight checks. Historical v1 `size`
values have inconsistent units, so v2 clients only require v1 `size` to be
non-zero and verify the downloaded SHA-512 instead. Publish v2 metadata before
releasing clients that consume it; clients retain v1 compatibility for custom
update servers.
## Building a release

View File

@@ -7,9 +7,9 @@
# download URL as "<base>/<name>", so "name" must be the tarball file name.
# - server/service/application/update.go verifies the download against
# "sha512", which is the *base64* encoding of the raw SHA-512 digest.
# - server/service/application/install.go untars the package and moves the
# single top-level directory over /kvmapp, so the tarball must contain
# exactly one root directory: nanokvm_<version>/.
# - server/service/application/archive.go validates and extracts the package
# before install.go moves its single top-level directory over /kvmapp, so
# the tarball must contain exactly one root directory: nanokvm_<version>/.
#
# Build artifacts are expected to be in place already (see
# scripts/build-in-container.sh and the "web" target in the Makefile):
@@ -157,7 +157,7 @@ printf '0\n' > "$STAGE/kvm/now_fps"
printf '60\n' > "$STAGE/kvm/qlty"
printf '1920\n' > "$STAGE/kvm/width"
printf '1080\n' > "$STAGE/kvm/height"
printf '1\n' > "$STAGE/kvm/state"
printf '0\n' > "$STAGE/kvm/state"
printf 'mjpeg\n' > "$STAGE/kvm/type"
printf '0' > "$STAGE/kvm/res"
@@ -199,14 +199,29 @@ fi
# --- manifest ----------------------------------------------------------------
# update.go compares base64(raw sha512), not the hex digest.
SHA512="$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)"
SIZE="$(wc -c < "$TARBALL" | tr -d ' ')"
SIZE_BYTES="$(wc -c < "$TARBALL" | tr -d ' ')"
UNPACKED_SIZE_BYTES="$(python3 - "$TARBALL" <<'PY'
import sys
import tarfile
total = 0
with tarfile.open(sys.argv[1], "r:gz") as archive:
for member in archive:
if member.isfile():
total += member.size
print(total)
PY
)"
cat > "$MANIFEST" <<EOF
{
"manifest_version": 2,
"version": "$VERSION",
"name": "nanokvm_$VERSION.tar.gz",
"sha512": "$SHA512",
"size": $SIZE
"size": $SIZE_BYTES,
"size_bytes": $SIZE_BYTES,
"unpacked_size_bytes": $UNPACKED_SIZE_BYTES
}
EOF

View File

@@ -28,6 +28,9 @@ for path in "$TARBALL" "$MANIFEST" "$CHECKSUM"; do
done
PACKAGE_ROOT="nanokvm_${VERSION}"
MAX_PACKAGE_SIZE=$((1 << 30))
MAX_UNPACKED_SIZE=$((2 << 30))
MAX_ARCHIVE_ENTRIES=100000
ENTRY_LIST=$(mktemp)
VERBOSE_LIST=$(mktemp)
trap 'rm -f "$ENTRY_LIST" "$VERBOSE_LIST"' EXIT
@@ -44,6 +47,10 @@ fi
ENTRY_COUNT=0
while IFS= read -r entry; do
ENTRY_COUNT=$((ENTRY_COUNT + 1))
if [ "$ENTRY_COUNT" -gt "$MAX_ARCHIVE_ENTRIES" ]; then
echo "[ERROR] archive contains more than $MAX_ARCHIVE_ENTRIES entries" >&2
exit 1
fi
case "$entry" in
"$PACKAGE_ROOT"|"$PACKAGE_ROOT"/*) ;;
*)
@@ -92,6 +99,9 @@ fi
MANIFEST_VERSION=$(jq -er '.version | select(type == "string" and length > 0)' "$MANIFEST")
MANIFEST_NAME=$(jq -er '.name | select(type == "string" and length > 0)' "$MANIFEST")
MANIFEST_SIZE=$(jq -er '.size | select(type == "number" and . >= 0 and floor == .)' "$MANIFEST")
MANIFEST_FORMAT=$(jq -er '.manifest_version | select(type == "number" and . == 2)' "$MANIFEST")
MANIFEST_SIZE_BYTES=$(jq -er '.size_bytes | select(type == "number" and . > 0 and floor == .)' "$MANIFEST")
MANIFEST_UNPACKED_SIZE_BYTES=$(jq -er '.unpacked_size_bytes | select(type == "number" and . > 0 and floor == .)' "$MANIFEST")
MANIFEST_SHA512=$(jq -er '.sha512 | select(type == "string" and length > 0)' "$MANIFEST")
if [ "$MANIFEST_VERSION" != "$VERSION" ]; then
@@ -104,10 +114,39 @@ if [ "$MANIFEST_NAME" != "$TARBALL_NAME" ]; then
fi
ACTUAL_SIZE=$(wc -c < "$TARBALL" | tr -d ' ')
if [ "$MANIFEST_SIZE" != "$ACTUAL_SIZE" ]; then
if [ "$MANIFEST_FORMAT" != "2" ]; then
echo "[ERROR] latest.json manifest_version must be 2" >&2
exit 1
fi
if [ "$MANIFEST_SIZE" != "$ACTUAL_SIZE" ] || [ "$MANIFEST_SIZE_BYTES" != "$ACTUAL_SIZE" ]; then
echo "[ERROR] latest.json size '$MANIFEST_SIZE' does not match '$ACTUAL_SIZE'" >&2
exit 1
fi
if [ "$ACTUAL_SIZE" -gt "$MAX_PACKAGE_SIZE" ]; then
echo "[ERROR] release tarball exceeds device limit of $MAX_PACKAGE_SIZE bytes" >&2
exit 1
fi
ACTUAL_UNPACKED_SIZE=$(python3 - "$TARBALL" <<'PY'
import sys
import tarfile
total = 0
with tarfile.open(sys.argv[1], "r:gz") as archive:
for member in archive:
if member.isfile():
total += member.size
print(total)
PY
)
if [ "$MANIFEST_UNPACKED_SIZE_BYTES" != "$ACTUAL_UNPACKED_SIZE" ]; then
echo "[ERROR] latest.json unpacked_size_bytes '$MANIFEST_UNPACKED_SIZE_BYTES' does not match '$ACTUAL_UNPACKED_SIZE'" >&2
exit 1
fi
if [ "$ACTUAL_UNPACKED_SIZE" -gt "$MAX_UNPACKED_SIZE" ]; then
echo "[ERROR] unpacked package exceeds device limit of $MAX_UNPACKED_SIZE bytes" >&2
exit 1
fi
ACTUAL_SHA512=$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)
if [ "$MANIFEST_SHA512" != "$ACTUAL_SHA512" ]; then

View File

@@ -48,7 +48,7 @@ authentication: enable # Whether to enable identity verification fo
jwt:
secretKey: "" # The secret key used to sign and verify JWT Tokens. If left empty, a random key will be generated automatically on startup
refreshTokenDuration: 2678400 # The token refresh duration threshold in seconds before forcing a re-login. Default is `2678400` (~31 days)
revokeTokensOnLogout: true # Whether to invalidate all existing tokens upon logout by rotating the SecretKey. Default is `true`
revokeTokensOnLogout: true # Whether logout invalidates all sessions belonging to that user. Other users are never logged out. Setting this to false only clears the browser cookie and is not recommended. Default is `true`
security:
loginLockoutDuration: 0, # The duration (in seconds) to ban an IP from attempting to log in again after reaching the failure limit. If set to `0` or left empty, brute-force protection is disabled. Default is `0`
loginMaxFailures: 5, # The maximum number of continuous failed login attempts allowed per IP before triggering protection. Default is `5`
@@ -62,9 +62,110 @@ turn:
turnCred: example_cred # The credential/password required for authorization to the TURN server
```
## Web Users
NanoKVM uses two device-wide roles:
- `admin`: everything below, plus user management.
- `user`: only what the account is granted.
A grant names an action on one party, and the catalog the server serves to the account editor
carries its group, its risk, and the grants it implies:
| Group | Grants |
| --- | --- |
| console (this session) | `console.view`, `console.control` |
| target machine | `power`, `medium.attach` |
| media library (this device) | `library.read`, `library.write` |
| this device | `terminal`, `device`, `network`, `update` |
| services | `mcp`, `tailscale`, `ai` |
Implied grants are declared once, in the catalog, and the server resolves them before answering any
request: `console.control` implies `console.view`, both `medium.attach` and `library.write` imply
`library.read`, and every grant whose controls live in the console menu — `power`, `device`,
`network`, `update`, `mcp`, `tailscale` and `ai` — implies `console.view` too, because no page
outside the console renders them. Consequences worth knowing:
- An account can hold no console access at all. The web app then lands on a page that offers the
sections the account may use (the media library, the terminal) instead of a console it cannot
open. `library.read` is all that media access needs, because the other two media grants imply it,
and only `terminal` and the media grants can stand on their own pages in the first place.
- The media grants split along the trust boundary. `medium.attach` mounts or ejects a medium on the
target machine and writes into the shared partition the target reads; `library.write` changes what
the library keeps (uploads, deletes, the files inside an image); `library.read` lists and downloads.
The file endpoints serve an image and the shared partition through one path, so the route admits
either grant and each request is checked against the medium type it names. Every `.iso` and
`.img` on the partition is a library entry, so uploading one through the shared partition, or
deleting a file or folder that holds one, also needs `library.write`.
- The emulated USB devices are neither media nor network: the virtual disk and the RNDIS adapter
hand the target machine the raw data partition and a network interface, so both follow `device`.
A medium can only be attached while a `device` holder has the virtual disk switched on, and the
storage page says so when it is off.
Four grants reach further than their names suggest and amount to full device control. The catalog
has two risk levels, and `warn` is reserved for them: the account editor marks those four and
names them before the grants are saved:
- `terminal` is a root shell (the same one SSH gives) and can run saved and autostart scripts.
- `mcp` hands out a device-wide API key that drives the keyboard, mouse and screenshots from
outside the web session. The key is shared, so a copy keeps working after an account loses this
grant. Whenever a change takes the grant away (revoked, demoted, disabled or deleted), the account
editor warns and offers to regenerate the key, checked by default; regenerating disconnects every
MCP client until it is given the new key, and an administrator who keeps the key accepts that a
copy stays valid. The API takes the choice as `rotateMcpKey` (a body field on update, a query
parameter on delete) and never rotates without it.
- `ai` drives the device through the local loopback interface, which the other grants do not
restrict.
- `update` installs firmware and application packages. An offline package is checked only against
the checksum its uploader supplies, so installing one is running code as root.
`device` carries no such mark, although it changes what the device does for everyone and cannot be
narrowed per account. `device` turns SSH on or off (root login, with the device
owner's password), reboots the device, hands the raw data partition or a network interface to the
target machine through the emulated USB devices, and edits the OLED, HDMI, mDNS, memory, swap and
HID settings. The editor describes it that way.
Two grants also change settings that affect everyone watching: `console.view` sets the video mode,
resolution, frame rate and quality, and resets the HDMI capture; `power` manages the Wake-on-LAN
device list. Both are described that way in the editor.
Administrators manage accounts from **Settings > Account**. Account data remains in
`/etc/kvm/pwd`; the server migrates the legacy single-account JSON format in place and writes
the multi-user format atomically with mode `0600`. Keeping the same path preserves the physical
BOOT-button password reset behavior.
Users must confirm their current password when changing it themselves; administrators can reset
non-owner users from the account manager. Only the device owner can change the device owner's
password, because that password is also synchronized to the Linux root account.
All authenticated sessions are backed by the current account state: every request is authorized
against the account file, so a revoked grant stops working immediately without re-issuing a login.
Disabling, deleting, or resetting the password of a user invalidates that user's HTTP and real-time
connections without affecting other users. Changing the role or the grants of an account keeps its
login valid: the account is told to refresh, and only the long-lived connections whose cached
state depends on what changed are dropped (console, video stream, terminal, AI), so those clients
reconnect under the new grants. An account that loses `console.view` is sent to the landing page
instead of a console it can no longer open. Multiple
users may watch and control the KVM concurrently; input uses the existing cooperative HID
coordinator, so simultaneous input can interleave. A user without `console.control` keeps the
video and the menu for its other grants, but the console rejects keyboard and mouse reports and
the web UI marks the session as view only.
Video mode, quality, resolution, MJPEG frame-detection controls, and resetting the HDMI capture
require only `console.view` and stay shared KVM operations; when several users adjust them
concurrently, the latest change applies device-wide. Enabling or disabling the HDMI capture and
its idle timeout are device settings. `console.control` is input only: keyboard, mouse, paste, and
the input region that maps pointer motion onto the target screen.
The account file stores an explicit grant set: a record written before fine-grained permissions
keeps exactly what the user role used to grant, and the split media grants are translated on load,
so the store never holds a missing set that would fall back to different behavior later. Grants
this build does not know are preserved and ignored, so a downgrade cannot lock the device out.
Creating a regular account without naming a grant set keeps that same meaning, so a script that only
knows about roles gets the account the user role used to create; the account editor seeds a new
regular account with `console.view` only, and an explicitly empty set really is empty.
## Compile & Deploy
Note: Use Linux operating system (x86-64) with Go 1.25 or newer. This build process is not compatible with ARM, Windows or macOS.
Note: The manual steps below require a Linux x86-64 host with Go 1.25 or newer; they are not compatible with ARM, Windows or macOS. With Docker you can skip them entirely and use the containerized flow instead — the root [Makefile](../Makefile) (`make shell`) or the dev container (see "Development" in the root [README](../README.md)) — which works on any host OS; run `server/build.sh` inside the container for a release-equivalent build.
1. Install the Toolchain
1. Download the toolchain from the following link: [Download Link](https://sophon-file.sophon.cn/sophon-prod-s3/drive/23/03/07/16/host-tools.tar.gz).

View File

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

322
server/authn/permissions.go Normal file
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)
}
}

719
server/authn/store.go Normal file
View File

@@ -0,0 +1,719 @@
package authn
import (
"crypto/rand"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sync"
"NanoKVM-Server/utils"
"golang.org/x/crypto/bcrypt"
)
const (
AccountFile = "/etc/kvm/pwd"
currentFileVersion = 1
defaultUsername = "admin"
defaultPassword = "admin"
)
type Role string
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrUserExists = errors.New("username already exists")
ErrLastAdmin = errors.New("at least one enabled admin is required")
ErrSelfModification = errors.New("administrators cannot disable or demote themselves")
ErrSelfDelete = errors.New("administrators cannot delete themselves")
ErrSystemAccount = errors.New("the device owner account must remain an enabled administrator")
usernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$`)
)
type User struct {
Username string `json:"username"`
PasswordHash string `json:"password"`
Role Role `json:"role"`
Enabled bool `json:"enabled"`
TokenVersion uint64 `json:"tokenVersion"`
Permissions []Permission `json:"permissions"`
MustChangePassword bool `json:"mustChangePassword,omitempty"`
SystemAccount bool `json:"systemAccount,omitempty"`
}
type UserInfo struct {
Username string `json:"username"`
Role Role `json:"role"`
Enabled bool `json:"enabled"`
Permissions []Permission `json:"permissions"`
SystemAccount bool `json:"systemAccount,omitempty"`
}
type UserPatch struct {
Role *Role
Enabled *bool
Permissions *[]Permission
}
type database struct {
Version int `json:"version"`
Users []User `json:"users"`
LegacyUsername string `json:"username,omitempty"`
LegacyPassword string `json:"password,omitempty"`
}
type legacyAccount struct {
Username string `json:"username"`
Password string `json:"password"`
}
type Store struct {
path string
mutex sync.RWMutex
}
var DefaultStore = NewStore(AccountFile)
func NewStore(path string) *Store {
return &Store{path: path}
}
func IsValidRole(role Role) bool {
return role == RoleAdmin || role == RoleUser
}
func ValidateUsername(username string) error {
if !usernamePattern.MatchString(username) {
return errors.New("username must be 1-32 characters and contain only letters, numbers, '.', '_' or '-'")
}
return nil
}
func ValidatePassword(password string) error {
length := len([]byte(password))
if length < 8 || length > 72 {
return errors.New("password must be between 8 and 72 bytes")
}
return nil
}
func (s *Store) List() ([]UserInfo, error) {
s.mutex.RLock()
defer s.mutex.RUnlock()
db, err := s.loadLocked(false)
if err != nil {
return nil, err
}
users := make([]UserInfo, 0, len(db.Users))
for index := range db.Users {
user := &db.Users[index]
// The stored grants, not the effective ones: the editor shows what an
// administrator set, and what an account implies is derived from the
// catalog instead of being written back on every save.
users = append(users, UserInfo{
Username: user.Username,
Role: user.Role,
Enabled: user.Enabled,
Permissions: clonePermissions(user.Permissions),
SystemAccount: user.SystemAccount,
})
}
return users, nil
}
func (s *Store) Get(username string) (*User, error) {
s.mutex.RLock()
defer s.mutex.RUnlock()
db, err := s.loadLocked(false)
if err != nil {
return nil, err
}
return findUser(db.Users, username)
}
func (s *Store) Authenticate(username, password string) (*User, bool, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
db, err := s.loadLocked(true)
if err != nil {
return nil, false, err
}
user, err := findUser(db.Users, username)
if err != nil || !user.Enabled {
return nil, false, nil
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) == nil {
return user, true, nil
}
// Older releases stored a reversibly encrypted password. Upgrade it after
// the first successful login so the compatibility path is not permanent.
legacyPassword, decodeErr := utils.DecodeDecrypt(user.PasswordHash)
if decodeErr != nil || legacyPassword != password {
return nil, false, nil
}
hash, hashErr := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if hashErr != nil {
return nil, false, hashErr
}
for index := range db.Users {
if db.Users[index].Username == username {
db.Users[index].PasswordHash = string(hash)
if db.Users[index].TokenVersion == 0 {
db.Users[index].TokenVersion = 1
}
user = cloneUser(&db.Users[index])
break
}
}
if err = s.saveLocked(db); err != nil {
return nil, false, err
}
return user, true, nil
}
func (s *Store) ValidateToken(username string, tokenVersion uint64) (*User, error) {
user, err := s.Get(username)
if err != nil {
return nil, err
}
if !user.Enabled || user.TokenVersion != tokenVersion {
return nil, errors.New("session revoked")
}
return user, nil
}
func (s *Store) Create(username, password string, role Role, permissions ...Permission) error {
if err := ValidateUsername(username); err != nil {
return err
}
if err := ValidatePassword(password); err != nil {
return err
}
if !IsValidRole(role) {
return errors.New("invalid role")
}
// A nil set means the caller named none at all, which keeps the meaning it
// had before grants existed: an account created by a script that only knows
// about roles gets what the user role granted then. An explicit empty set is
// a decision and stays empty, so the account really holds nothing.
if permissions == nil && role == RoleUser {
permissions = legacyUserPermissions()
}
if err := ValidatePermissions(permissions); err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
tokenVersion, err := newTokenVersion()
if err != nil {
return err
}
s.mutex.Lock()
defer s.mutex.Unlock()
db, err := s.loadLocked(true)
if err != nil {
return err
}
if _, err = findUser(db.Users, username); err == nil {
return ErrUserExists
}
db.Users = append(db.Users, User{
Username: username,
PasswordHash: string(hash),
Role: role,
Enabled: true,
TokenVersion: tokenVersion,
Permissions: NormalizeStoredPermissions(permissions),
})
return s.saveLocked(db)
}
// Update applies the patch and returns the account as it was before and after,
// both read under the store lock, so a caller comparing the two sees exactly the
// change it made even while another update runs.
func (s *Store) Update(actor, username string, patch UserPatch) (previous, updated *User, err error) {
s.mutex.Lock()
defer s.mutex.Unlock()
db, err := s.loadLocked(true)
if err != nil {
return nil, nil, err
}
index := userIndex(db.Users, username)
if index < 0 {
return nil, nil, ErrUserNotFound
}
previous = cloneUser(&db.Users[index])
user := db.Users[index]
if patch.Role != nil && !IsValidRole(*patch.Role) {
return nil, nil, errors.New("invalid role")
}
// The patch is normalized before it is compared and stored, so a caller that
// still sends the split media grants does not look like a change once they
// resolve to the grants they now mean.
granted := user.Permissions
if patch.Permissions != nil {
if err := ValidatePermissions(*patch.Permissions); err != nil {
return nil, nil, err
}
granted = NormalizeStoredPermissions(*patch.Permissions)
// A request can only name grants this build knows, so the ones it does
// not (written by a newer release) are carried over instead of being
// dropped by an editor that never showed them.
for _, permission := range user.Permissions {
if !IsValidPermission(permission) && !Has(granted, permission) {
granted = append(granted, permission)
}
}
}
if actor == username && user.Role == RoleAdmin &&
((patch.Role != nil && *patch.Role != RoleAdmin) || (patch.Enabled != nil && !*patch.Enabled)) {
return nil, nil, ErrSelfModification
}
if user.SystemAccount &&
((patch.Role != nil && *patch.Role != RoleAdmin) || (patch.Enabled != nil && !*patch.Enabled)) {
return nil, nil, ErrSystemAccount
}
changed := false
// Only disabling the account invalidates its login. A role change does not:
// the principal is rebuilt from the account file on every request, and the
// caller refreshes the user's long-lived connections so they reconnect under
// the role's new grants. Bumping the token version for a role change would
// log the account out mid-session just because it was promoted or demoted.
loginInvalidated := false
if patch.Role != nil && user.Role != *patch.Role {
user.Role = *patch.Role
changed = true
}
if patch.Enabled != nil && user.Enabled != *patch.Enabled {
user.Enabled = *patch.Enabled
changed = true
loginInvalidated = true
}
if patch.Permissions != nil && !permissionsEqual(user.Permissions, granted) {
user.Permissions = granted
changed = true
}
if !changed {
return previous, cloneUser(&user), nil
}
db.Users[index] = user
if enabledAdminCount(db.Users) == 0 {
return nil, nil, ErrLastAdmin
}
// Permission and role updates keep the login valid: the principal is rebuilt
// from the store on every request, so new grants apply immediately and
// revoked ones are enforced by dropping connections from the handler.
if loginInvalidated {
db.Users[index].TokenVersion++
}
if err = s.saveLocked(db); err != nil {
return nil, nil, err
}
return previous, cloneUser(&db.Users[index]), nil
}
func (s *Store) Delete(actor, username string) error {
if actor == username {
return ErrSelfDelete
}
s.mutex.Lock()
defer s.mutex.Unlock()
db, err := s.loadLocked(true)
if err != nil {
return err
}
index := userIndex(db.Users, username)
if index < 0 {
return ErrUserNotFound
}
if db.Users[index].SystemAccount {
return ErrSystemAccount
}
users := append([]User(nil), db.Users[:index]...)
users = append(users, db.Users[index+1:]...)
if enabledAdminCount(users) == 0 {
return ErrLastAdmin
}
db.Users = users
return s.saveLocked(db)
}
func (s *Store) SetPassword(username, password string) (*User, error) {
return s.SetPasswordAndRun(username, password, nil)
}
// SetPasswordAndRun commits the web password before running the optional
// system-side update. If that update fails, the account record is rolled back
// while the store lock is still held.
func (s *Store) SetPasswordAndRun(username, password string, afterCommit func() error) (*User, error) {
if err := ValidatePassword(password); err != nil {
return nil, err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
s.mutex.Lock()
defer s.mutex.Unlock()
db, err := s.loadLocked(true)
if err != nil {
return nil, err
}
index := userIndex(db.Users, username)
if index < 0 {
return nil, ErrUserNotFound
}
previous := *db
previous.Users = append([]User(nil), db.Users...)
db.Users[index].PasswordHash = string(hash)
db.Users[index].MustChangePassword = false
db.Users[index].TokenVersion++
if err = s.saveLocked(db); err != nil {
return nil, err
}
if afterCommit != nil {
if err = afterCommit(); err != nil {
if rollbackErr := s.saveLocked(&previous); rollbackErr != nil {
return nil, fmt.Errorf("system password update failed: %v; account rollback failed: %w", err, rollbackErr)
}
return nil, err
}
}
return cloneUser(&db.Users[index]), nil
}
func (s *Store) Revoke(username string) (*User, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
db, err := s.loadLocked(true)
if err != nil {
return nil, err
}
index := userIndex(db.Users, username)
if index < 0 {
return nil, ErrUserNotFound
}
db.Users[index].TokenVersion++
if err = s.saveLocked(db); err != nil {
return nil, err
}
return cloneUser(&db.Users[index]), nil
}
func (s *Store) loadLocked(migrate bool) (*database, error) {
data, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
db, defaultErr := defaultDatabase()
if defaultErr != nil {
return nil, defaultErr
}
materializePermissions(db)
if migrate {
if saveErr := s.saveLocked(db); saveErr != nil {
return nil, saveErr
}
}
return db, nil
}
if err != nil {
return nil, err
}
var db database
if err = json.Unmarshal(data, &db); err == nil && db.Version != 0 {
materializePermissions(&db)
if err = validateDatabase(&db); err != nil {
return nil, err
}
return &db, nil
}
var legacy legacyAccount
if err = json.Unmarshal(data, &legacy); err != nil || legacy.Username == "" || legacy.Password == "" {
return nil, errors.New("invalid account file")
}
if err = ValidateUsername(legacy.Username); err != nil {
return nil, fmt.Errorf("invalid legacy username: %w", err)
}
tokenVersion, err := newTokenVersion()
if err != nil {
return nil, err
}
db = database{
Version: currentFileVersion,
Users: []User{{
Username: legacy.Username,
PasswordHash: legacy.Password,
Role: RoleAdmin,
Enabled: true,
TokenVersion: tokenVersion,
MustChangePassword: passwordMatches(legacy.Password, defaultPassword),
SystemAccount: true,
}},
}
materializePermissions(&db)
if migrate {
if err = s.saveLocked(&db); err != nil {
return nil, err
}
}
return &db, nil
}
func (s *Store) saveLocked(db *database) error {
if err := validateDatabase(db); err != nil {
return err
}
syncLegacyAccount(db)
data, err := json.MarshalIndent(db, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
directory := filepath.Dir(s.path)
if err = os.MkdirAll(directory, 0o755); err != nil {
return err
}
temporary, err := os.CreateTemp(directory, ".pwd-*")
if err != nil {
return err
}
temporaryPath := temporary.Name()
defer func() { _ = os.Remove(temporaryPath) }()
if err = temporary.Chmod(0o600); err == nil {
_, err = temporary.Write(data)
}
if err == nil {
err = temporary.Sync()
}
closeErr := temporary.Close()
if err == nil {
err = closeErr
}
if err != nil {
return err
}
if err = os.Rename(temporaryPath, s.path); err != nil {
return err
}
dir, err := os.Open(directory)
if err != nil {
return err
}
defer func() { _ = dir.Close() }()
return dir.Sync()
}
func defaultDatabase() (*database, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(defaultPassword), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
tokenVersion, err := newTokenVersion()
if err != nil {
return nil, err
}
return &database{
Version: currentFileVersion,
Users: []User{{
Username: defaultUsername,
PasswordHash: string(hash),
Role: RoleAdmin,
Enabled: true,
TokenVersion: tokenVersion,
MustChangePassword: true,
SystemAccount: true,
}},
}, nil
}
func newTokenVersion() (uint64, error) {
var data [8]byte
for {
if _, err := rand.Read(data[:]); err != nil {
return 0, err
}
version := binary.LittleEndian.Uint64(data[:])
if version != 0 && version != ^uint64(0) {
return version, nil
}
}
}
func validateDatabase(db *database) error {
if db.Version != currentFileVersion {
return fmt.Errorf("unsupported account file version: %d", db.Version)
}
if len(db.Users) == 0 {
return errors.New("account file contains no users")
}
seen := make(map[string]struct{}, len(db.Users))
systemAccounts := 0
for index := range db.Users {
user := &db.Users[index]
if err := ValidateUsername(user.Username); err != nil {
return err
}
if user.PasswordHash == "" || !IsValidRole(user.Role) {
return errors.New("account file contains an invalid user")
}
if err := ValidateStoredPermissions(user.Permissions); err != nil {
return fmt.Errorf("account file contains invalid permissions for %q: %w", user.Username, err)
}
if _, exists := seen[user.Username]; exists {
return errors.New("account file contains duplicate usernames")
}
seen[user.Username] = struct{}{}
if user.TokenVersion == 0 {
user.TokenVersion = 1
}
if user.SystemAccount {
systemAccounts++
if user.Role != RoleAdmin || !user.Enabled || systemAccounts > 1 {
return ErrSystemAccount
}
}
}
if enabledAdminCount(db.Users) == 0 {
return ErrLastAdmin
}
return nil
}
func findUser(users []User, username string) (*User, error) {
index := userIndex(users, username)
if index < 0 {
return nil, ErrUserNotFound
}
return cloneUser(&users[index]), nil
}
func userIndex(users []User, username string) int {
for index := range users {
if users[index].Username == username {
return index
}
}
return -1
}
// materializePermissions replaces the grant shapes of earlier releases with an
// explicit set: an account that predates fine-grained permissions keeps exactly
// what the user role used to grant, and the split media grants are translated.
// After this the store never holds a nil set, so "no grants" and "never
// configured" can no longer be confused, and no caller has to special-case nil.
func materializePermissions(db *database) {
for index := range db.Users {
if db.Users[index].Permissions == nil {
db.Users[index].Permissions = legacyUserPermissions()
continue
}
db.Users[index].Permissions = NormalizeStoredPermissions(db.Users[index].Permissions)
}
}
func clonePermissions(permissions []Permission) []Permission {
return append([]Permission{}, permissions...)
}
func permissionsEqual(a, b []Permission) bool {
if (a == nil) != (b == nil) {
return false
}
if len(a) != len(b) {
return false
}
// The stored order carries no meaning, so a reordering must not look like
// a change worth writing back.
seen := make(map[Permission]struct{}, len(a))
for _, permission := range a {
seen[permission] = struct{}{}
}
for _, permission := range b {
if _, exists := seen[permission]; !exists {
return false
}
}
return true
}
func enabledAdminCount(users []User) int {
count := 0
for _, user := range users {
if user.Role == RoleAdmin && user.Enabled {
count++
}
}
return count
}
func cloneUser(user *User) *User {
if user == nil {
return nil
}
copy := *user
return &copy
}
func passwordMatches(hash, password string) bool {
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil {
return true
}
legacyPassword, err := utils.DecodeDecrypt(hash)
return err == nil && legacyPassword == password
}
// Keep a legacy top-level account mirror so downgrading to a single-user
// server still authenticates the original device owner instead of failing
// open or forcing an immediate physical reset.
func syncLegacyAccount(db *database) {
db.LegacyUsername = ""
db.LegacyPassword = ""
var fallback *User
for _, user := range db.Users {
if user.SystemAccount {
db.LegacyUsername = user.Username
db.LegacyPassword = user.PasswordHash
return
}
if fallback == nil && user.Role == RoleAdmin && user.Enabled {
copy := user
fallback = &copy
}
}
if fallback != nil {
db.LegacyUsername = fallback.Username
db.LegacyPassword = fallback.PasswordHash
}
}

212
server/authn/store_test.go Normal file
View File

@@ -0,0 +1,212 @@
package authn
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"golang.org/x/crypto/bcrypt"
)
func TestLegacyAccountMigratesInPlace(t *testing.T) {
path := filepath.Join(t.TempDir(), "pwd")
hash, err := bcrypt.GenerateFromPassword([]byte("legacy-password"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
legacy, _ := json.Marshal(legacyAccount{Username: "owner", Password: string(hash)})
if err = os.WriteFile(path, legacy, 0o644); err != nil {
t.Fatal(err)
}
store := NewStore(path)
user, ok, err := store.Authenticate("owner", "legacy-password")
if err != nil || !ok {
t.Fatalf("authenticate migrated account: ok=%v err=%v", ok, err)
}
if user.Role != RoleAdmin || !user.SystemAccount || user.TokenVersion == 0 {
t.Fatalf("unexpected migrated user: %+v", user)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var db database
if err = json.Unmarshal(data, &db); err != nil {
t.Fatalf("account file was not migrated: %v", err)
}
if db.Version != currentFileVersion || len(db.Users) != 1 {
t.Fatalf("unexpected database: %+v", db)
}
var downgrade legacyAccount
if err = json.Unmarshal(data, &downgrade); err != nil || downgrade.Username != "owner" || downgrade.Password == "" {
t.Fatalf("legacy downgrade mirror is invalid: %+v, %v", downgrade, err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("account mode = %o, want 600", info.Mode().Perm())
}
}
func TestCorruptAccountFileFailsClosed(t *testing.T) {
path := filepath.Join(t.TempDir(), "pwd")
if err := os.WriteFile(path, []byte("not json"), 0o600); err != nil {
t.Fatal(err)
}
_, ok, err := NewStore(path).Authenticate("admin", "admin")
if err == nil || ok {
t.Fatalf("corrupt file must fail closed: ok=%v err=%v", ok, err)
}
}
func TestUserLifecycleAndTokenRevocation(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "pwd"))
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
if err := store.Create("alice", "correct-horse", RoleUser); err != nil {
t.Fatal(err)
}
if _, ok, err := store.Authenticate("alice", "wrong-password"); err != nil || ok {
t.Fatalf("wrong password login: ok=%v err=%v", ok, err)
}
alice, err := store.Get("alice")
if err != nil {
t.Fatal(err)
}
if _, err = store.ValidateToken("alice", alice.TokenVersion); err != nil {
t.Fatal(err)
}
if _, err = store.SetPassword("alice", "new-password"); err != nil {
t.Fatal(err)
}
if _, err = store.ValidateToken("alice", alice.TokenVersion); err == nil {
t.Fatal("old token stayed valid after password change")
}
if _, ok, err := store.Authenticate("alice", "new-password"); err != nil || !ok {
t.Fatalf("new password login: ok=%v err=%v", ok, err)
}
disabled := false
if _, _, err = store.Update("admin", "alice", UserPatch{Enabled: &disabled}); err != nil {
t.Fatal(err)
}
if _, ok, err := store.Authenticate("alice", "new-password"); err != nil || ok {
t.Fatalf("disabled login: ok=%v err=%v", ok, err)
}
enabled := true
if _, _, err = store.Update("admin", "alice", UserPatch{Enabled: &enabled}); err != nil {
t.Fatal(err)
}
if err = store.Delete("admin", "alice"); err != nil {
t.Fatal(err)
}
if _, err = store.Get("alice"); !errors.Is(err, ErrUserNotFound) {
t.Fatalf("deleted user lookup error = %v", err)
}
}
func TestLastAdminAndSelfProtection(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "pwd"))
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
userRole := RoleUser
if _, _, err := store.Update("admin", "admin", UserPatch{Role: &userRole}); !errors.Is(err, ErrSelfModification) {
t.Fatalf("self demotion error = %v", err)
}
if err := store.Delete("admin", "admin"); !errors.Is(err, ErrSelfDelete) {
t.Fatalf("self delete error = %v", err)
}
if err := store.Create("second", "second-password", RoleAdmin); err != nil {
t.Fatal(err)
}
disabled := false
if _, _, err := store.Update("second", "admin", UserPatch{Enabled: &disabled}); !errors.Is(err, ErrSystemAccount) {
t.Fatalf("disable device owner error = %v", err)
}
if _, _, err := store.Update("second", "admin", UserPatch{Role: &userRole}); !errors.Is(err, ErrSystemAccount) {
t.Fatalf("demote device owner error = %v", err)
}
if err := store.Delete("second", "admin"); !errors.Is(err, ErrSystemAccount) {
t.Fatalf("delete device owner error = %v", err)
}
if err := store.Delete("admin", "second"); err != nil {
t.Fatal(err)
}
}
func TestRemovedAccountFileAndRecreatedUserInvalidateOldVersions(t *testing.T) {
path := filepath.Join(t.TempDir(), "pwd")
store := NewStore(path)
admin, ok, err := store.Authenticate("admin", "admin")
if err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
if err = os.Remove(path); err != nil {
t.Fatal(err)
}
if _, err = store.ValidateToken("admin", admin.TokenVersion); err == nil {
t.Fatal("old admin token survived account-file reset")
}
if _, ok, err = store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("login after reset: ok=%v err=%v", ok, err)
}
if err = store.Create("alice", "alice-password", RoleUser); err != nil {
t.Fatal(err)
}
alice, err := store.Get("alice")
if err != nil {
t.Fatal(err)
}
if err = store.Delete("admin", "alice"); err != nil {
t.Fatal(err)
}
if err = store.Create("alice", "alice-password", RoleUser); err != nil {
t.Fatal(err)
}
if _, err = store.ValidateToken("alice", alice.TokenVersion); err == nil {
t.Fatal("old token survived deletion and username reuse")
}
}
func TestConcurrentCreatesDoNotLoseUsers(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "pwd"))
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
const count = 5
var workers sync.WaitGroup
errorsCh := make(chan error, count)
for index := 0; index < count; index++ {
workers.Add(1)
go func(index int) {
defer workers.Done()
errorsCh <- store.Create(fmt.Sprintf("user%d", index), "valid-password", RoleUser)
}(index)
}
workers.Wait()
close(errorsCh)
for err := range errorsCh {
if err != nil {
t.Fatal(err)
}
}
users, err := store.List()
if err != nil {
t.Fatal(err)
}
if len(users) != count+1 {
t.Fatalf("got %d users, want %d", len(users), count+1)
}
}

View File

@@ -18,7 +18,10 @@ var (
kvmVisionOnce sync.Once
)
type KvmVision struct{}
type KvmVision struct {
mutex sync.RWMutex
closed bool
}
func GetKvmVision() *KvmVision {
kvmVisionOnce.Do(func() {
@@ -33,6 +36,12 @@ func GetKvmVision() *KvmVision {
}
func (k *KvmVision) ReadMjpeg(width uint16, height uint16, quality uint16) (data []byte, result int) {
k.mutex.RLock()
defer k.mutex.RUnlock()
if k.closed {
return nil, -1
}
var (
kvmData *C.uint8_t
dataSize C.uint32_t
@@ -57,6 +66,12 @@ func (k *KvmVision) ReadMjpeg(width uint16, height uint16, quality uint16) (data
}
func (k *KvmVision) ReadH264(width uint16, height uint16, bitRate uint16) (data []byte, result int) {
k.mutex.RLock()
defer k.mutex.RUnlock()
if k.closed {
return nil, -1
}
var (
kvmData *C.uint8_t
dataSize C.uint32_t
@@ -81,6 +96,12 @@ func (k *KvmVision) ReadH264(width uint16, height uint16, bitRate uint16) (data
}
func (k *KvmVision) SetHDMI(enable bool) int {
k.mutex.RLock()
defer k.mutex.RUnlock()
if k.closed {
return -1
}
hdmiEnable := C.uint8_t(0)
if enable {
hdmiEnable = C.uint8_t(1)
@@ -95,17 +116,46 @@ func (k *KvmVision) SetHDMI(enable bool) int {
return result
}
func (k *KvmVision) HasHDMISignal() bool {
k.mutex.RLock()
defer k.mutex.RUnlock()
if k.closed {
return false
}
return C.kvmv_hdmi_signal_active() != 0
}
func (k *KvmVision) SetGop(gop uint8) {
k.mutex.RLock()
defer k.mutex.RUnlock()
if k.closed {
return
}
_gop := C.uint8_t(gop)
C.set_h264_gop(_gop)
}
func (k *KvmVision) SetFrameDetect(frame uint8) {
k.mutex.RLock()
defer k.mutex.RUnlock()
if k.closed {
return
}
_frame := C.uint8_t(frame)
C.set_frame_detact(_frame)
}
func (k *KvmVision) Close() {
k.mutex.Lock()
defer k.mutex.Unlock()
if k.closed {
return
}
k.closed = true
C.kvmv_deinit()
log.Debugf("stop kvm vision...")
}

View File

@@ -7,13 +7,6 @@ import (
"time"
)
// RegenerateSecretKey regenerate secret key when logout
func RegenerateSecretKey() {
if instance.JWT.RevokeTokensOnLogout {
instance.JWT.SecretKey = generateRandomSecretKey()
}
}
// Generate random string for secret key.
func generateRandomSecretKey() string {
b := make([]byte, 64)

Binary file not shown.

View File

@@ -14,6 +14,7 @@ require (
github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/pion/dtls/v3 v3.1.2
github.com/pion/interceptor v0.1.39
github.com/pion/rtp v1.8.18
github.com/pion/webrtc/v4 v4.0.1
github.com/rs/cors/wrapper/gin v0.0.0-20240830163046-1084d89a1692
@@ -21,6 +22,7 @@ require (
github.com/spf13/viper v1.19.0
github.com/unrolled/secure v1.15.0
golang.org/x/crypto v0.45.0
golang.org/x/sys v0.41.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -47,7 +49,6 @@ require (
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pion/datachannel v1.5.9 // indirect
github.com/pion/ice/v4 v4.0.2 // indirect
github.com/pion/interceptor v0.1.39 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/randutil v0.1.0 // indirect
@@ -79,7 +80,6 @@ require (
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect

View File

@@ -66,6 +66,7 @@ void set_h264_gop(uint8_t _gop);
void set_frame_detact(uint8_t _frame_detact);
void kvmv_deinit();
uint8_t kvmv_hdmi_control(uint8_t _en);
uint8_t kvmv_hdmi_signal_active(void);
#ifdef __cplusplus
}

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

@@ -1,68 +1,223 @@
package middleware
import (
"context"
"errors"
"net/http"
"time"
"NanoKVM-Server/authn"
"NanoKVM-Server/config"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
log "github.com/sirupsen/logrus"
"NanoKVM-Server/config"
)
const (
principalContextKey = "principal"
tokenContextKey = "token"
CookieName = "nano-kvm-token"
sessionRecheckDelay = 5 * time.Second
)
type Principal struct {
Username string
Role authn.Role
Permissions []authn.Permission
}
// Has reports whether the principal effectively holds the permission.
// Administrators always do.
func (p Principal) Has(permission authn.Permission) bool {
if p.Role == authn.RoleAdmin {
return true
}
return authn.Has(p.Permissions, permission)
}
type Token struct {
Username string `json:"username"`
Username string `json:"username"`
TokenVersion uint64 `json:"tokenVersion"`
jwt.RegisteredClaims
}
func CheckToken() gin.HandlerFunc {
return func(c *gin.Context) {
if allowByToken(c) {
principal, token, ok := authenticate(c)
if !ok {
abortUnauthorized(c)
return
}
c.Set(principalContextKey, principal)
c.Set(tokenContextKey, token)
if token == nil || token.ExpiresAt == nil {
c.Next()
return
}
abortUnauthorized(c)
requestContext, cancel := context.WithCancel(c.Request.Context())
sessionHandle := newSessionHandle()
requestContext = WithSessionHandle(requestContext, sessionHandle)
c.Request = c.Request.WithContext(requestContext)
channel := SessionChannelForPath(c.Request.URL.Path)
unregister := activeSessions.register(
principal.Username,
cancel,
sessionHandle,
channel,
)
// The principal was resolved before this session could be found by a
// revocation, so a grant edit landing in between would miss it. Now that
// it is registered, any later edit drops it; re-reading the account picks
// up any earlier one. Only the long-lived channels are dropped by a grant
// edit, so a short request keeps the principal it was authorized with.
if channel != "" {
user, err := authn.DefaultStore.ValidateToken(principal.Username, token.TokenVersion)
if err != nil {
unregister()
cancel()
abortUnauthorized(c)
return
}
principal.Role = user.Role
principal.Permissions = authn.EffectivePermissions(user)
c.Set(principalContextKey, principal)
}
timer := time.AfterFunc(time.Until(token.ExpiresAt.Time), cancel)
go watchSessionState(requestContext, cancel, principal.Username, token.TokenVersion, sessionRecheckDelay)
defer func() {
timer.Stop()
unregister()
cancel()
}()
c.Next()
}
}
func watchSessionState(ctx context.Context, cancel context.CancelFunc, username string, tokenVersion uint64, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if _, err := authn.DefaultStore.ValidateToken(username, tokenVersion); err != nil {
cancel()
return
}
}
}
}
func RequireRole(roles ...authn.Role) gin.HandlerFunc {
allowed := make(map[authn.Role]struct{}, len(roles))
for _, role := range roles {
allowed[role] = struct{}{}
}
return func(c *gin.Context) {
principal, ok := CurrentPrincipal(c)
if !ok {
abortUnauthorized(c)
return
}
if _, ok = allowed[principal.Role]; !ok {
abortForbidden(c)
return
}
c.Next()
}
}
// RequirePermission allows the request when the principal holds at least one
// of the listed permissions. Administrators implicitly hold every permission.
func RequirePermission(permissions ...authn.Permission) gin.HandlerFunc {
return func(c *gin.Context) {
principal, ok := CurrentPrincipal(c)
if !ok {
abortUnauthorized(c)
return
}
allowed := false
for _, permission := range permissions {
if principal.Has(permission) {
allowed = true
break
}
}
if !allowed {
abortForbidden(c)
return
}
c.Next()
}
}
func CurrentPrincipal(c *gin.Context) (Principal, bool) {
value, exists := c.Get(principalContextKey)
if !exists {
return Principal{}, false
}
principal, ok := value.(Principal)
return principal, ok
}
func CheckLoopbackInternalToken() gin.HandlerFunc {
return func(c *gin.Context) {
if allowByLoopbackInternalToken(c.Request) {
c.Next()
return
}
abortUnauthorized(c)
}
}
func CheckTokenOrLoopbackInternalToken() gin.HandlerFunc {
return func(c *gin.Context) {
if allowByToken(c) || allowByLoopbackInternalToken(c.Request) {
if allowByLoopbackInternalToken(c.Request) {
c.Next()
return
}
abortUnauthorized(c)
principal, token, ok := authenticate(c)
if !ok {
abortUnauthorized(c)
return
}
c.Set(principalContextKey, principal)
c.Set(tokenContextKey, token)
c.Next()
}
}
func allowByToken(c *gin.Context) bool {
func authenticate(c *gin.Context) (Principal, *Token, bool) {
conf := config.GetInstance()
if conf.Authentication == "disable" {
return true
return Principal{Username: "admin", Role: authn.RoleAdmin, Permissions: authn.PermissionIDs()}, nil, true
}
cookie, err := c.Cookie("nano-kvm-token")
cookie, err := c.Cookie(CookieName)
if err != nil {
return false
return Principal{}, nil, false
}
_, err = ParseJWT(cookie)
return err == nil
token, err := ParseJWT(cookie)
if err != nil {
return Principal{}, nil, false
}
user, err := authn.DefaultStore.ValidateToken(token.Username, token.TokenVersion)
if err != nil {
log.Debugf("validate session for %q: %s", token.Username, err)
return Principal{}, nil, false
}
return Principal{
Username: user.Username,
Role: user.Role,
Permissions: authn.EffectivePermissions(user),
}, token, true
}
func abortUnauthorized(c *gin.Context) {
@@ -70,37 +225,49 @@ func abortUnauthorized(c *gin.Context) {
c.Abort()
}
func GenerateJWT(username string) (string, error) {
func abortForbidden(c *gin.Context) {
c.JSON(http.StatusForbidden, "forbidden")
c.Abort()
}
func GenerateJWT(username string, tokenVersion uint64) (string, error) {
conf := config.GetInstance()
now := time.Now()
expireDuration := time.Duration(conf.JWT.RefreshTokenDuration) * time.Second
claims := Token{
Username: username,
Username: username,
TokenVersion: tokenVersion,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expireDuration)),
Subject: username,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(expireDuration)),
},
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return t.SignedString([]byte(conf.JWT.SecretKey))
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(conf.JWT.SecretKey))
}
func ParseJWT(jwtToken string) (*Token, error) {
conf := config.GetInstance()
t, err := jwt.ParseWithClaims(jwtToken, &Token{}, func(token *jwt.Token) (interface{}, error) {
return []byte(conf.JWT.SecretKey), nil
})
parsed, err := jwt.ParseWithClaims(
jwtToken,
&Token{},
func(token *jwt.Token) (interface{}, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, errors.New("unexpected signing method")
}
return []byte(conf.JWT.SecretKey), nil
},
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
jwt.WithExpirationRequired(),
)
if err != nil {
log.Debugf("parse jwt error: %s", err)
return nil, err
}
if claims, ok := t.Claims.(*Token); ok && t.Valid {
return claims, nil
} else {
return nil, err
claims, ok := parsed.Claims.(*Token)
if !ok || !parsed.Valid || claims.Username == "" || claims.Subject != claims.Username || claims.TokenVersion == 0 {
return nil, errors.New("invalid token claims")
}
return claims, nil
}

View File

@@ -0,0 +1,230 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"NanoKVM-Server/authn"
"NanoKVM-Server/config"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/websocket"
)
func TestCheckTokenUsesLiveRoleAndVersion(t *testing.T) {
gin.SetMode(gin.TestMode)
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
admin, ok, err := store.Authenticate("admin", "admin")
if err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
if err = store.Create("alice", "valid-password", authn.RoleUser); err != nil {
t.Fatal(err)
}
alice, err := store.Get("alice")
if err != nil {
t.Fatal(err)
}
restore := useTestAuthStore(t, store)
defer restore()
adminToken, err := GenerateJWT(admin.Username, admin.TokenVersion)
if err != nil {
t.Fatal(err)
}
userToken, err := GenerateJWT(alice.Username, alice.TokenVersion)
if err != nil {
t.Fatal(err)
}
router := gin.New()
router.GET("/admin", CheckToken(), RequireRole(authn.RoleAdmin), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
if status := requestWithToken(router, "/admin", ""); status != http.StatusUnauthorized {
t.Fatalf("missing token status = %d", status)
}
if status := requestWithToken(router, "/admin", adminToken); status != http.StatusNoContent {
t.Fatalf("admin status = %d", status)
}
if status := requestWithToken(router, "/admin", userToken); status != http.StatusForbidden {
t.Fatalf("user status = %d", status)
}
if _, err = store.Revoke("admin"); err != nil {
t.Fatal(err)
}
if status := requestWithToken(router, "/admin", adminToken); status != http.StatusUnauthorized {
t.Fatalf("revoked status = %d", status)
}
}
func TestParseJWTRejectsOtherHMACMethods(t *testing.T) {
conf := config.GetInstance()
originalSecret := conf.JWT.SecretKey
conf.JWT.SecretKey = "test-secret"
defer func() { conf.JWT.SecretKey = originalSecret }()
now := time.Now()
claims := Token{
Username: "admin",
TokenVersion: 1,
RegisteredClaims: jwt.RegisteredClaims{
Subject: "admin",
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
},
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS384, claims).SignedString([]byte(conf.JWT.SecretKey))
if err != nil {
t.Fatal(err)
}
if _, err = ParseJWT(token); err == nil {
t.Fatal("HS384 token was accepted")
}
}
func TestParseJWTRejectsExpiredToken(t *testing.T) {
conf := config.GetInstance()
originalSecret := conf.JWT.SecretKey
conf.JWT.SecretKey = "test-secret"
defer func() { conf.JWT.SecretKey = originalSecret }()
claims := Token{
Username: "admin",
TokenVersion: 1,
RegisteredClaims: jwt.RegisteredClaims{
Subject: "admin",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
},
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(conf.JWT.SecretKey))
if err != nil {
t.Fatal(err)
}
if _, err = ParseJWT(token); err == nil {
t.Fatal("expired token was accepted")
}
}
func TestRevokeUserSessionsCancelsActiveRequests(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
unregister := activeSessions.register("alice", cancel, newSessionHandle(), "")
defer unregister()
RevokeUserSessions("alice")
select {
case <-ctx.Done():
case <-time.After(time.Second):
t.Fatal("active session was not cancelled")
}
}
func TestAccountFileResetCancelsActiveSession(t *testing.T) {
path := filepath.Join(t.TempDir(), "pwd")
store := authn.NewStore(path)
user, ok, err := store.Authenticate("admin", "admin")
if err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
restore := useTestAuthStore(t, store)
defer restore()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go watchSessionState(ctx, cancel, user.Username, user.TokenVersion, 10*time.Millisecond)
if err = os.Remove(path); err != nil {
t.Fatal(err)
}
select {
case <-ctx.Done():
case <-time.After(2 * time.Second):
t.Fatal("active session survived account-file reset")
}
}
func TestWatchWebSocketClosesRevokedConnection(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
connection, err := upgrader.Upgrade(writer, request, nil)
if err != nil {
return
}
defer connection.Close()
stop := WatchWebSocket(ctx, connection)
defer stop()
_, _, _ = connection.ReadMessage()
}))
defer server.Close()
wsURL := "ws" + server.URL[len("http"):]
connection, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatal(err)
}
defer connection.Close()
cancel()
_ = connection.SetReadDeadline(time.Now().Add(2 * time.Second))
_, _, err = connection.ReadMessage()
if !websocket.IsCloseError(err, SessionRevokedCloseCode) {
t.Fatalf("close error = %v, want code %d", err, SessionRevokedCloseCode)
}
}
func TestCheckWebSocketOrigin(t *testing.T) {
tests := []struct {
origin string
host string
want bool
}{
{"", "kvm.local", true},
{"http://kvm.local", "kvm.local", true},
{"http://KVM.local", "kvm.local", true},
{"http://kvm.local:8080", "kvm.local:8080", true},
{"http://kvm.local:8081", "kvm.local:8080", false},
{"http://evil.example", "kvm.local", false},
}
for _, test := range tests {
request := httptest.NewRequest(http.MethodGet, "http://"+test.host+"/api/ws", nil)
request.Host = test.host
if test.origin != "" {
request.Header.Set("Origin", test.origin)
}
if got := CheckWebSocketOrigin(request); got != test.want {
t.Fatalf("origin %q host %q = %v, want %v", test.origin, test.host, got, test.want)
}
}
}
func requestWithToken(handler http.Handler, path, token string) int {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, path, nil)
request.AddCookie(&http.Cookie{Name: CookieName, Value: token})
handler.ServeHTTP(recorder, request)
return recorder.Code
}
func useTestAuthStore(t *testing.T, store *authn.Store) func() {
t.Helper()
originalStore := authn.DefaultStore
conf := config.GetInstance()
originalAuthentication := conf.Authentication
originalSecret := conf.JWT.SecretKey
originalDuration := conf.JWT.RefreshTokenDuration
authn.DefaultStore = store
conf.Authentication = "enable"
conf.JWT.SecretKey = "test-secret"
conf.JWT.RefreshTokenDuration = 3600
return func() {
authn.DefaultStore = originalStore
conf.Authentication = originalAuthentication
conf.JWT.SecretKey = originalSecret
conf.JWT.RefreshTokenDuration = originalDuration
}
}

View File

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

View File

@@ -12,3 +12,13 @@ type GetPreviewRsp struct {
type SetPreviewReq struct {
Enable bool `validate:"omitempty"`
}
type GetUpdateServerRsp struct {
Enabled bool `json:"enabled"`
URL string `json:"url"`
}
type SetUpdateServerReq struct {
Enabled *bool `json:"enabled" form:"enabled" validate:"required"`
URL string `json:"url" form:"url"`
}

View File

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

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
}

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

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

View File

@@ -20,7 +20,7 @@ func ValidateRequest(req interface{}) error {
}
if env == "" || env == "debug" {
log.Debugf("request: %+v\n", req)
log.Debugf("request validated: %T", req)
}
return nil

View File

@@ -72,6 +72,10 @@ type GetVirtualDeviceRsp struct {
type UpdateVirtualDeviceReq struct {
Device string `validate:"required"`
// Enable turns the device on or off instead of toggling it. A caller that
// says "enable" must not switch a running device off because it happened to
// be already on.
Enable *bool `json:"enable"`
}
type UpdateVirtualDeviceRsp struct {
@@ -99,6 +103,7 @@ type GetOLEDRsp struct {
type GetGetHdmiStateRsp struct {
Enabled bool `json:"enabled"`
Signal bool `json:"signal"`
IdleTimeout int `json:"idleTimeout"`
}
@@ -151,3 +156,54 @@ type GetWebTitleRsp struct {
type SetTlsReq struct {
Enabled bool `validate:"omitempty"`
}
type InputRegion struct {
Mode string `json:"mode"`
FrameWidth int `json:"frameWidth"`
FrameHeight int `json:"frameHeight"`
Left int `json:"left"`
Top int `json:"top"`
Width int `json:"width"`
Height int `json:"height"`
Resolutions []OriginalResolution `json:"resolutions,omitempty"`
SelectedResolution string `json:"selectedResolution"`
Regions []ManualRegion `json:"regions,omitempty"`
SelectedRegion string `json:"selectedRegion"`
}
type ManualRegion struct {
FrameWidth int `json:"frameWidth"`
FrameHeight int `json:"frameHeight"`
Left int `json:"left"`
Top int `json:"top"`
Width int `json:"width"`
Height int `json:"height"`
}
type OriginalResolution struct {
Width int `json:"width"`
Height int `json:"height"`
}
type SetInputRegionReq struct {
Mode string `json:"mode"`
FrameWidth *int `json:"frameWidth,omitempty"`
FrameHeight *int `json:"frameHeight,omitempty"`
Left *int `json:"left,omitempty"`
Top *int `json:"top,omitempty"`
Width *int `json:"width,omitempty"`
Height *int `json:"height,omitempty"`
Resolutions *[]OriginalResolution `json:"resolutions,omitempty"`
SelectedResolution *string `json:"selectedResolution,omitempty"`
Regions *[]ManualRegion `json:"regions,omitempty"`
SelectedRegion *string `json:"selectedRegion,omitempty"`
}
type GetInputRegionRsp struct {
InputRegion
}
type GetInputResolutionRsp struct {
Width int `json:"width"`
Height int `json:"height"`
}

View File

@@ -1,6 +1,7 @@
package router
import (
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/application"
@@ -9,7 +10,7 @@ import (
func applicationRouter(r *gin.Engine) {
service := application.NewService()
api := r.Group("/api").Use(middleware.CheckToken())
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermUpdate))
api.GET("/application/version", service.GetVersion) // get application version
api.POST("/application/update", service.Update) // update application
@@ -17,4 +18,7 @@ func applicationRouter(r *gin.Engine) {
api.GET("/application/preview", service.GetPreview) // get preview updates state
api.POST("/application/preview", service.SetPreview) // set preview updates state
api.GET("/application/update-server", service.GetUpdateServer) // get custom update server
api.POST("/application/update-server", service.SetUpdateServer) // set custom update server
}

View File

@@ -3,6 +3,7 @@ package router
import (
"github.com/gin-gonic/gin"
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/auth"
)
@@ -18,4 +19,15 @@ func authRouter(r *gin.Engine) {
api.GET("/auth/account", service.GetAccount) // get account
api.POST("/auth/password", service.ChangePassword) // change password
api.POST("/auth/logout", service.Logout) // logout
admin := r.Group("/api").Use(
middleware.CheckToken(),
middleware.RequireRole(authn.RoleAdmin),
)
admin.GET("/auth/permissions", service.ListPermissions)
admin.GET("/auth/users", service.ListUsers)
admin.POST("/auth/users", service.CreateUser)
admin.PUT("/auth/users/:username", service.UpdateUser)
admin.DELETE("/auth/users/:username", service.DeleteUser)
admin.POST("/auth/users/:username/password", service.ChangeUserPassword)
}

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"time"
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/hid"
@@ -19,7 +20,10 @@ type setAIControlModeRequest struct {
}
func controlRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *picoclaw.Service) {
group := r.Group("/api/ai/control").Use(middleware.CheckToken())
group := r.Group("/api/ai/control").Use(
middleware.CheckToken(),
middleware.RequirePermission(authn.PermAi),
)
group.GET("/status", func(c *gin.Context) {
status, err := control.Status()
if err != nil {

View File

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

View File

@@ -1,6 +1,7 @@
package router
import (
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/extensions/tailscale"
@@ -8,7 +9,10 @@ import (
)
func extensionsRouter(r *gin.Engine) {
api := r.Group("/api/extensions").Use(middleware.CheckToken())
api := r.Group("/api/extensions").Use(
middleware.CheckToken(),
middleware.RequirePermission(authn.PermTailscale),
)
ts := tailscale.NewService()

View File

@@ -3,6 +3,7 @@ package router
import (
"github.com/gin-gonic/gin"
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/hid"
)
@@ -15,22 +16,24 @@ func HIDLoopbackHTTPAllowedPaths() []string {
func hidRouter(r *gin.Engine) {
service := hid.NewService()
api := r.Group("/api").Use(middleware.CheckToken())
view := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermConsoleView))
control := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermConsoleControl))
system := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermDevice))
localAPI := r.Group("/api/internal").Use(middleware.CheckLoopbackInternalToken())
api.POST("/hid/paste", service.Paste) // paste
control.POST("/hid/paste", service.Paste) // paste
api.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
api.POST("/hid/shortcut", service.AddShortcut) // add shortcut
api.DELETE("/hid/shortcut", service.DeleteShortcut) // delete shortcut
view.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
view.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // get shortcut leader key
api.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // set shortcut leader key
api.POST("/hid/shortcut/leader-key", service.SetLeaderKey) // set shortcut leader key
view.GET("/hid/mode", service.GetHidMode) // get hid mode
view.GET("/hid/leds", service.GetKeyboardLedStatus)
api.GET("/hid/mode", service.GetHidMode) // get hid mode
api.POST("/hid/mode", service.SetHidMode) // set hid mode
api.POST("/hid/reset", service.ResetHid) // reset hid
api.GET("/hid/leds", service.GetKeyboardLedStatus)
system.POST("/hid/mode", service.SetHidMode) // set hid mode
system.POST("/hid/reset", service.ResetHid) // reset hid
system.POST("/hid/shortcut", service.AddShortcut)
system.DELETE("/hid/shortcut", service.DeleteShortcut)
system.POST("/hid/shortcut/leader-key", service.SetLeaderKey)
localAPI.POST("/usb/recover", service.RecoverUSB)
}

View File

@@ -3,7 +3,9 @@ package router
import (
"net/http"
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/auth"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/hid"
mcpservice "NanoKVM-Server/service/mcp"
@@ -23,7 +25,12 @@ func mcpRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *pic
picoclawService.PublishControlModeChangedFrom(status, "mcp_config")
},
)
management := r.Group("/api/mcp").Use(middleware.CheckToken())
auth.APIKeyRotator = mcpservice.RotateAPIKey
management := r.Group("/api/mcp").Use(
middleware.CheckToken(),
middleware.RequirePermission(authn.PermMcp),
)
management.GET("/config", service.GetConfig)
management.POST("/config", service.SetConfig)
management.POST("/key/regenerate", service.RegenerateAPIKey)

View File

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

View File

@@ -3,6 +3,7 @@ package router
import (
"github.com/gin-gonic/gin"
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/picoclaw"
)
@@ -39,7 +40,10 @@ func PicoclawLoopbackHTTPAllowedPaths() []string {
}
func picoclawRouter(r *gin.Engine, service *picoclaw.Service) {
frontendAPI := r.Group(picoclawBasePath).Use(middleware.CheckToken())
frontendAPI := r.Group(picoclawBasePath).Use(
middleware.CheckToken(),
middleware.RequirePermission(authn.PermAi),
)
localAPI := r.Group(picoclawBasePath).Use(middleware.CheckLoopbackInternalToken())
localAPI.GET(picoclawScreenshotPath, service.Screenshot)

View File

@@ -5,8 +5,10 @@ import (
"os"
"path/filepath"
"NanoKVM-Server/service/auth"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/picoclaw"
"NanoKVM-Server/service/ws"
"github.com/gin-gonic/contrib/static"
"github.com/gin-gonic/gin"
@@ -35,6 +37,8 @@ func server(r *gin.Engine) {
control := controlmode.GetManager()
picoclawService := picoclaw.NewService(control)
auth.SessionNotifier = ws.GetManager().NotifyUser
authRouter(r)
applicationRouter(r)
vmRouter(r)

View File

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

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

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

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

@@ -0,0 +1,203 @@
package application
import (
"archive/tar"
"compress/gzip"
"errors"
"fmt"
"io"
"math"
"os"
"path"
"path/filepath"
"strings"
)
type updateArchiveInfo struct {
root string
expandedBytes uint64
entries int
}
func inspectUpdateArchive(archivePath, expectedRoot string) (updateArchiveInfo, error) {
file, err := os.Open(archivePath)
if err != nil {
return updateArchiveInfo{}, err
}
defer file.Close()
reader, err := gzip.NewReader(file)
if err != nil {
return updateArchiveInfo{}, fmt.Errorf("open gzip archive: %w", err)
}
defer reader.Close()
info := updateArchiveInfo{root: expectedRoot}
seen := make(map[string]struct{})
hasRoot, hasVersion := false, false
tarReader := tar.NewReader(reader)
for {
header, err := tarReader.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return updateArchiveInfo{}, fmt.Errorf("read tar archive: %w", err)
}
name, err := validateArchiveHeader(header, expectedRoot)
if err != nil {
return updateArchiveInfo{}, err
}
if _, ok := seen[name]; ok {
return updateArchiveInfo{}, fmt.Errorf("duplicate archive entry %q", name)
}
seen[name] = struct{}{}
info.entries++
if info.entries > maxArchiveEntries {
return updateArchiveInfo{}, fmt.Errorf("update archive has more than %d entries", maxArchiveEntries)
}
if name == expectedRoot && header.Typeflag == tar.TypeDir {
hasRoot = true
}
if name == expectedRoot+"/version" && isRegularType(header.Typeflag) {
hasVersion = true
}
if isRegularType(header.Typeflag) {
if header.Size < 0 {
return updateArchiveInfo{}, fmt.Errorf("negative archive file size")
}
size := uint64(header.Size)
if size > maxExpandedSize-info.expandedBytes {
return updateArchiveInfo{}, fmt.Errorf("update archive expanded size exceeds %d bytes", maxExpandedSize)
}
if info.expandedBytes > math.MaxUint64-size {
return updateArchiveInfo{}, fmt.Errorf("update archive expanded size overflow")
}
info.expandedBytes += size
}
}
if !hasRoot {
return updateArchiveInfo{}, fmt.Errorf("invalid update package layout: missing top-level directory")
}
if !hasVersion {
return updateArchiveInfo{}, fmt.Errorf("invalid update package layout: missing version file")
}
return info, nil
}
func extractUpdateArchive(archivePath, destDir, expectedRoot string) (string, error) {
file, err := os.Open(archivePath)
if err != nil {
return "", err
}
defer file.Close()
reader, err := gzip.NewReader(file)
if err != nil {
return "", fmt.Errorf("open gzip archive: %w", err)
}
defer reader.Close()
seen := make(map[string]struct{})
entries := 0
expanded := uint64(0)
tarReader := tar.NewReader(reader)
for {
header, err := tarReader.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return "", fmt.Errorf("read tar archive: %w", err)
}
name, err := validateArchiveHeader(header, expectedRoot)
if err != nil {
return "", err
}
if _, ok := seen[name]; ok {
return "", fmt.Errorf("duplicate archive entry %q", name)
}
seen[name] = struct{}{}
entries++
if entries > maxArchiveEntries {
return "", fmt.Errorf("update archive has more than %d entries", maxArchiveEntries)
}
filename := filepath.Join(destDir, filepath.FromSlash(name))
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(filename, 0o755); err != nil {
return "", fmt.Errorf("create archive directory: %w", err)
}
case tar.TypeReg, tar.TypeRegA:
if header.Size < 0 {
return "", fmt.Errorf("negative archive file size")
}
size := uint64(header.Size)
if size > maxExpandedSize-expanded {
return "", fmt.Errorf("update archive expanded size exceeds %d bytes", maxExpandedSize)
}
expanded += size
if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil {
return "", fmt.Errorf("create archive parent directory: %w", err)
}
out, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_EXCL, os.FileMode(header.Mode)&0o777)
if err != nil {
return "", fmt.Errorf("create archive file: %w", err)
}
written, copyErr := io.CopyN(out, tarReader, header.Size)
closeErr := out.Close()
if copyErr != nil {
return "", fmt.Errorf("write archive file: %w", copyErr)
}
if written != header.Size {
return "", fmt.Errorf("truncated archive file %q", name)
}
if closeErr != nil {
return "", fmt.Errorf("close archive file: %w", closeErr)
}
}
}
return filepath.Join(destDir, expectedRoot), nil
}
func validateArchiveHeader(header *tar.Header, expectedRoot string) (string, error) {
if header.Name == "" || path.IsAbs(header.Name) {
return "", fmt.Errorf("invalid archive path %q", header.Name)
}
name := path.Clean(header.Name)
if name == "." || name == ".." || strings.HasPrefix(name, "../") {
return "", fmt.Errorf("invalid archive path %q", header.Name)
}
if name != expectedRoot && !strings.HasPrefix(name, expectedRoot+"/") {
return "", fmt.Errorf("invalid update package layout: entry %q is outside %s", header.Name, expectedRoot)
}
if !isRegularType(header.Typeflag) && header.Typeflag != tar.TypeDir {
return "", fmt.Errorf("unsupported archive entry type for %q", header.Name)
}
return name, nil
}
func isRegularType(typeFlag byte) bool {
return typeFlag == tar.TypeReg || typeFlag == tar.TypeRegA
}
func validateExtractedPackage(rootDir, expectedVersion string) error {
version, err := os.ReadFile(filepath.Join(rootDir, "version"))
if err != nil {
return fmt.Errorf("invalid update package layout: read version: %w", err)
}
if strings.TrimSpace(string(version)) != expectedVersion {
return fmt.Errorf("invalid update package layout: version mismatch")
}
for _, required := range []string{
"version",
"server/NanoKVM-Server",
"kvm_system/kvm_system",
"system/init.d/S95nanokvm",
} {
info, err := os.Stat(filepath.Join(rootDir, filepath.FromSlash(required)))
if err != nil || !info.Mode().IsRegular() {
return fmt.Errorf("invalid update package layout: missing required file %s", required)
}
}
return nil
}

View File

@@ -0,0 +1,90 @@
package application
import (
"archive/tar"
"compress/gzip"
"os"
"path/filepath"
"testing"
)
type archiveEntry struct {
name string
typeFlag byte
data string
}
func writeTestArchive(t *testing.T, entries []archiveEntry) string {
t.Helper()
file := filepath.Join(t.TempDir(), "update.tar.gz")
out, err := os.Create(file)
if err != nil {
t.Fatal(err)
}
gzipWriter := gzip.NewWriter(out)
tarWriter := tar.NewWriter(gzipWriter)
for _, entry := range entries {
header := &tar.Header{Name: entry.name, Typeflag: entry.typeFlag, Mode: 0o755}
if entry.typeFlag == tar.TypeReg || entry.typeFlag == tar.TypeRegA {
header.Size = int64(len(entry.data))
}
if err := tarWriter.WriteHeader(header); err != nil {
t.Fatal(err)
}
if entry.data != "" {
if _, err := tarWriter.Write([]byte(entry.data)); err != nil {
t.Fatal(err)
}
}
}
if err := tarWriter.Close(); err != nil {
t.Fatal(err)
}
if err := gzipWriter.Close(); err != nil {
t.Fatal(err)
}
if err := out.Close(); err != nil {
t.Fatal(err)
}
return file
}
func TestInspectArchiveRejectsUnsafeEntries(t *testing.T) {
root := "nanokvm_1.2.3"
for _, entry := range []archiveEntry{
{name: "../escape", typeFlag: tar.TypeReg, data: "x"},
{name: "/escape", typeFlag: tar.TypeReg, data: "x"},
{name: root + "/link", typeFlag: tar.TypeSymlink},
} {
archive := writeTestArchive(t, []archiveEntry{{name: root, typeFlag: tar.TypeDir}, entry})
if _, err := inspectUpdateArchive(archive, root); err == nil {
t.Fatalf("unsafe entry %+v was accepted", entry)
}
}
}
func TestInspectAndExtractArchive(t *testing.T) {
root := "nanokvm_1.2.3"
archive := writeTestArchive(t, []archiveEntry{
{name: root, typeFlag: tar.TypeDir},
{name: root + "/version", typeFlag: tar.TypeReg, data: "1.2.3\n"},
{name: root + "/server/NanoKVM-Server", typeFlag: tar.TypeReg, data: "server"},
{name: root + "/kvm_system/kvm_system", typeFlag: tar.TypeReg, data: "system"},
{name: root + "/system/init.d/S95nanokvm", typeFlag: tar.TypeReg, data: "init"},
})
info, err := inspectUpdateArchive(archive, root)
if err != nil {
t.Fatal(err)
}
if info.expandedBytes != 22 { // data totals: 6 + 6 + 6 + 4
t.Fatalf("unexpected expanded size %d", info.expandedBytes)
}
destination := t.TempDir()
extracted, err := extractUpdateArchive(archive, destination, root)
if err != nil {
t.Fatal(err)
}
if err := validateExtractedPackage(extracted, "1.2.3"); err != nil {
t.Fatal(err)
}
}

View File

@@ -31,17 +31,12 @@ func releaseUpdateLock() {
isUpdating = false
}
func installPackage(source string) error {
dir, err := utils.UnTarGz(source, CacheDir)
if err != nil {
return fmt.Errorf("failed to decompress app: %w", err)
}
func installPreparedPackage(sourceDir string) error {
if err := backupCurrentApp(); err != nil {
return err
}
if err := applyUpdate(dir); err != nil {
if err := applyUpdate(sourceDir); err != nil {
return err
}

View File

@@ -7,11 +7,13 @@ import (
"strings"
"time"
"NanoKVM-Server/utils"
log "github.com/sirupsen/logrus"
)
const (
sentinelPath = "/tmp/.download_in_progress"
sentinelPath = utils.TransferSentinelPath
tickerInterval = 2500 * time.Millisecond
sentinelPermission = 0644
)

View File

@@ -7,6 +7,14 @@ const (
AppDir = "/kvmapp"
BackupDir = "/root/old"
CacheDir = "/root/.kvmcache"
updateWorkspacePrefix = "nanokvm-update-"
cacheDirMode = 0o700
maxPackageSize = uint64(1 << 30)
maxExpandedSize = uint64(2 << 30)
maxArchiveEntries = 100_000
minFreeReserve = uint64(128 << 20)
freeReservePercent = uint64(5)
)
type Service struct{}

View File

@@ -0,0 +1,103 @@
package application
import (
"errors"
"fmt"
"math"
"os"
"path/filepath"
"golang.org/x/sys/unix"
)
var ErrInsufficientStorage = errors.New("insufficient storage")
type filesystemSpace struct {
total uint64
available uint64
}
func getFilesystemSpace(path string) (filesystemSpace, error) {
var stat unix.Statfs_t
if err := unix.Statfs(path, &stat); err != nil {
return filesystemSpace{}, fmt.Errorf("stat filesystem %s: %w", path, err)
}
return filesystemSpaceFromStats(uint64(stat.Blocks), uint64(stat.Bavail), uint64(stat.Bsize))
}
func filesystemSpaceFromStats(blocks, availableBlocks, blockSize uint64) (filesystemSpace, error) {
if blockSize != 0 && (blocks > math.MaxUint64/blockSize || availableBlocks > math.MaxUint64/blockSize) {
return filesystemSpace{}, fmt.Errorf("filesystem size overflow")
}
return filesystemSpace{total: blocks * blockSize, available: availableBlocks * blockSize}, nil
}
func reserveBytes(total uint64) uint64 {
percent := total / 100 * freeReservePercent
percent += total % 100 * freeReservePercent / 100
if percent > minFreeReserve {
return percent
}
return minFreeReserve
}
func hasFreeSpace(space filesystemSpace, payload uint64) (bool, uint64, error) {
reserve := reserveBytes(space.total)
if payload > math.MaxUint64-reserve {
return false, 0, fmt.Errorf("storage requirement overflow")
}
required := payload + reserve
return space.available >= required, required, nil
}
func ensureFreeSpace(path string, payloadBytes uint64) error {
space, err := getFilesystemSpace(path)
if err != nil {
return err
}
ok, required, err := hasFreeSpace(space, payloadBytes)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("%w: need %d MiB including reserve, %d MiB available on %s", ErrInsufficientStorage, required>>20, space.available>>20, path)
}
return nil
}
func ensureExpandedSpace(path string, expandedBytes uint64) error {
// Keep the last known-good backup until the new package is fully prepared.
// Reclaiming it here can leave the device without rollback material even
// when extraction later fails or the reclaimed space is still insufficient.
return ensureFreeSpace(path, expandedBytes)
}
func ensureInstallFilesystem(workspaceDir, appDir, backupDir string) error {
backupPath := backupDir
if _, err := os.Stat(backupPath); err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("stat backup path %s: %w", backupPath, err)
}
backupPath = filepath.Dir(backupPath)
}
// AppDir already exists (prepareCacheForUpdate verifies it), so stat the
// directory itself. Its parent can be on another filesystem when AppDir is
// a mount point.
paths := []string{workspaceDir, appDir, backupPath}
var device uint64
for i, path := range paths {
var stat unix.Stat_t
if err := unix.Stat(path, &stat); err != nil {
return fmt.Errorf("stat install filesystem %s: %w", path, err)
}
if i == 0 {
device = uint64(stat.Dev)
continue
}
if uint64(stat.Dev) != device {
return fmt.Errorf("update workspace, application and backup must be on the same filesystem")
}
}
return nil
}

View File

@@ -0,0 +1,46 @@
package application
import (
"math"
"path/filepath"
"testing"
)
func TestReserveBytesAndSpaceBoundary(t *testing.T) {
if got := reserveBytes(1 << 30); got != minFreeReserve {
t.Fatalf("reserve for 1 GiB = %d, want %d", got, minFreeReserve)
}
if got := reserveBytes(4 << 30); got != 214748364 {
t.Fatalf("reserve for 4 GiB = %d, want 214748364", got)
}
space := filesystemSpace{total: 1 << 30, available: (1 << 20) + minFreeReserve}
ok, required, err := hasFreeSpace(space, 1<<20)
if err != nil || !ok || required != space.available {
t.Fatalf("boundary should fit: ok=%v required=%d err=%v", ok, required, err)
}
space.available--
ok, _, err = hasFreeSpace(space, 1<<20)
if err != nil || ok {
t.Fatalf("one byte short should fail: ok=%v err=%v", ok, err)
}
}
func TestStorageOverflowIsRejected(t *testing.T) {
if _, err := filesystemSpaceFromStats(math.MaxUint64, 1, 2); err == nil {
t.Fatal("expected filesystem overflow")
}
if _, _, err := hasFreeSpace(filesystemSpace{total: 1}, math.MaxUint64); err == nil {
t.Fatal("expected requirement overflow")
}
}
func TestInstallFilesystemChecksAppMountPoint(t *testing.T) {
workspace := t.TempDir()
backup := filepath.Join(t.TempDir(), "backup")
// /proc is a mount point whose parent is /. Checking filepath.Dir(appDir)
// would therefore miss the cross-filesystem layout on a normal Linux host.
if err := ensureInstallFilesystem(workspace, "/proc", backup); err == nil {
t.Fatal("separate application mount point was accepted")
}
}

View File

@@ -8,6 +8,8 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -28,9 +30,9 @@ func (s *Service) Update(c *gin.Context) {
rsp.ErrRsp(c, -1, "update already in progress")
return
}
defer releaseUpdateLock()
if err := update(); err != nil {
releaseUpdateLock()
rsp.ErrRsp(c, -1, fmt.Sprintf("update failed: %s", err))
return
}
@@ -38,40 +40,75 @@ func (s *Service) Update(c *gin.Context) {
rsp.OkRsp(c)
log.Debugf("update application success")
// Sleep for a second before restarting the device
go restartServices()
}
func restartServices() {
// Let the HTTP response reach the client before stopping the server.
time.Sleep(1 * time.Second)
_ = exec.Command("sh", "-c", "/etc/init.d/S95nanokvm restart").Run()
if err := exec.Command("/kvmapp/system/init.d/S95nanokvm", "restart").Run(); err != nil {
log.Errorf("failed to restart services after update: %v", err)
}
}
func update() error {
_ = os.RemoveAll(CacheDir)
_ = os.MkdirAll(CacheDir, 0o755)
defer func() {
_ = os.RemoveAll(CacheDir)
}()
// get latest information
latest, err := getLatest()
if err != nil {
return err
}
// download
target := fmt.Sprintf("%s/%s", CacheDir, latest.Name)
if err := download(latest.Url, target); err != nil {
log.Errorf("download app failed: %s", err)
if err := prepareCacheForUpdate(); err != nil {
return err
}
workspace, err := newUpdateWorkspace(CacheDir)
if err != nil {
return err
}
defer func() {
if err := workspace.Close(); err != nil {
log.Warnf("failed to clean update workspace %s: %v", workspace.dir, err)
}
}()
if err := ensureInstallFilesystem(workspace.dir, AppDir, BackupDir); err != nil {
return err
}
if err := preflightManifestSpace(workspace.dir, latest); err != nil {
return err
}
target := filepath.Join(workspace.dir, latest.Name)
downloadInfo, err := download(latest, target)
if err != nil {
log.Errorf("download app failed: %s", err)
return err
}
if err := validateDownloadedSize(latest, uint64(downloadInfo.Written)); err != nil {
return err
}
// check sha512
if err := checksum(target, latest.Sha512); err != nil {
log.Errorf("check sha512 failed: %s", err)
return err
}
// install
if err := installPackage(target); err != nil {
expectedRoot := strings.TrimSuffix(latest.Name, ".tar.gz")
info, err := inspectUpdateArchive(target, expectedRoot)
if err != nil {
return fmt.Errorf("inspect update package: %w", err)
}
if err := validateExpandedSize(latest, info.expandedBytes); err != nil {
return err
}
if err := ensureExpandedSpace(workspace.dir, info.expandedBytes); err != nil {
return err
}
sourceDir, err := extractUpdateArchive(target, workspace.dir, expectedRoot)
if err != nil {
return fmt.Errorf("extract update package: %w", err)
}
if err := validateExtractedPackage(sourceDir, latest.Version); err != nil {
return err
}
if err := installPreparedPackage(sourceDir); err != nil {
log.Errorf("failed to install package: %v", err)
return err
}
@@ -79,7 +116,7 @@ func update() error {
return nil
}
func download(url string, target string) (err error) {
func download(latest *Latest, target string) (info utils.DownloadInfo, err error) {
for i := range maxTries {
log.Debugf("attempt #%d/%d", i+1, maxTries)
if i > 0 {
@@ -87,21 +124,26 @@ func download(url string, target string) (err error) {
}
var req *http.Request
req, err = http.NewRequest("GET", url, nil)
req, err = utils.NewAuthenticatedRequest("GET", latest.Url, nil)
if err != nil {
log.Errorf("new request err: %s", err)
continue
}
log.Debugf("update will be saved to: %s", target)
err = utils.Download(req, target)
info, err = utils.Download(req, target, int64(maxPackageSize), func(contentLength int64) error {
if latest.ManifestVersion == 2 && uint64(contentLength) != latest.SizeBytes {
return fmt.Errorf("update package size mismatch: manifest has %d bytes, response has %d", latest.SizeBytes, contentLength)
}
return ensureFreeSpace(filepath.Dir(target), uint64(contentLength))
})
if err != nil {
log.Errorf("downloading latest application failed, try again...")
continue
}
return nil
return info, nil
}
return err
return utils.DownloadInfo{}, err
}
func checksum(filePath string, expectedHash string) error {

View File

@@ -1,15 +1,17 @@
package application
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
@@ -25,9 +27,9 @@ func (s *Service) OfflineUpdate(c *gin.Context) {
rsp.ErrRsp(c, -1, "update already in progress")
return
}
defer releaseUpdateLock()
if err := offlineUpdate(c); err != nil {
releaseUpdateLock()
rsp.ErrRsp(c, -1, fmt.Sprintf("update failed: %s", err))
return
}
@@ -35,16 +37,42 @@ func (s *Service) OfflineUpdate(c *gin.Context) {
rsp.OkRsp(c)
log.Debugf("offline update application success")
time.Sleep(1 * time.Second)
_ = exec.Command("sh", "-c", "/etc/init.d/S95nanokvm restart").Run()
go restartServices()
}
func offlineUpdate(c *gin.Context) error {
_ = os.RemoveAll(CacheDir)
_ = os.MkdirAll(CacheDir, 0o755)
expectedSHA256, err := parseSHA256Checksum(c.GetHeader("X-SHA256-Checksum"))
if err != nil {
return err
}
if err := prepareCacheForUpdate(); err != nil {
return err
}
workspace, err := newUpdateWorkspace(CacheDir)
if err != nil {
return err
}
defer func() {
_ = os.RemoveAll(CacheDir)
if err := workspace.Close(); err != nil {
log.Warnf("failed to clean update workspace %s: %v", workspace.dir, err)
}
}()
if err := ensureInstallFilesystem(workspace.dir, AppDir, BackupDir); err != nil {
return err
}
maxRequestBytes := int64(maxPackageSize) + (1 << 20)
contentLength := c.Request.ContentLength
if contentLength > maxRequestBytes {
return fmt.Errorf("offline update request exceeds %d bytes", maxRequestBytes)
}
if contentLength > 0 {
if err := ensureFreeSpace(workspace.dir, uint64(contentLength)); err != nil {
return err
}
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxRequestBytes)
if err := createSentinelFile(); err != nil {
return err
@@ -57,13 +85,35 @@ func offlineUpdate(c *gin.Context) error {
return fmt.Errorf("invalid multipart data: %w", err)
}
target, err := processUpload(reader, c.Request.ContentLength)
target, err := processUpload(reader, contentLength, workspace.dir)
if err != nil {
log.Errorf("failed to upload install package: %v", err)
return err
}
if err := installPackage(target); err != nil {
if err := verifySHA256Checksum(target, expectedSHA256); err != nil {
log.Errorf("failed to verify install package: %v", err)
return err
}
archiveName := filepath.Base(target)
expectedRoot := strings.TrimSuffix(archiveName, ".tar.gz")
expectedVersion := strings.TrimPrefix(expectedRoot, "nanokvm_")
info, err := inspectUpdateArchive(target, expectedRoot)
if err != nil {
return fmt.Errorf("inspect update package: %w", err)
}
if err := ensureExpandedSpace(workspace.dir, info.expandedBytes); err != nil {
return err
}
sourceDir, err := extractUpdateArchive(target, workspace.dir, expectedRoot)
if err != nil {
return fmt.Errorf("extract update package: %w", err)
}
if err := validateExtractedPackage(sourceDir, expectedVersion); err != nil {
return err
}
if err := installPreparedPackage(sourceDir); err != nil {
log.Errorf("failed to install package: %v", err)
return err
}
@@ -71,6 +121,43 @@ func offlineUpdate(c *gin.Context) error {
return nil
}
func parseSHA256Checksum(value string) ([]byte, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
checksum, err := hex.DecodeString(value)
if err != nil || len(checksum) != sha256.Size {
return nil, fmt.Errorf("invalid sha256 checksum")
}
return checksum, nil
}
func verifySHA256Checksum(filePath string, expected []byte) error {
if len(expected) == 0 {
return nil
}
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open uploaded file: %w", err)
}
defer file.Close()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return fmt.Errorf("failed to calculate sha256 checksum: %w", err)
}
if subtle.ConstantTimeCompare(hasher.Sum(nil), expected) != 1 {
return fmt.Errorf("sha256 checksum mismatch")
}
return nil
}
func createSentinelFile() error {
file, err := os.OpenFile(
sentinelPath,
@@ -98,7 +185,7 @@ func createSentinelFile() error {
return nil
}
func processUpload(reader *multipart.Reader, contentLength int64) (string, error) {
func processUpload(reader *multipart.Reader, contentLength int64, workspaceDir string) (string, error) {
var outPath string
for {
@@ -113,8 +200,11 @@ func processUpload(reader *multipart.Reader, contentLength int64) (string, error
if part.FormName() != "file" {
continue
}
if outPath != "" {
return "", fmt.Errorf("multiple files uploaded")
}
outPath, err = saveUploadedFile(part, contentLength)
outPath, err = saveUploadedFile(part, contentLength, workspaceDir)
if err != nil {
return "", err
}
@@ -127,7 +217,7 @@ func processUpload(reader *multipart.Reader, contentLength int64) (string, error
return outPath, nil
}
func saveUploadedFile(part *multipart.Part, contentLength int64) (string, error) {
func saveUploadedFile(part *multipart.Part, contentLength int64, workspaceDir string) (string, error) {
filename := part.FileName()
if filename == "" {
return "", fmt.Errorf("no filename provided")
@@ -137,19 +227,36 @@ func saveUploadedFile(part *multipart.Part, contentLength int64) (string, error)
return "", err
}
outPath := filepath.Join(CacheDir, filename)
out, err := os.Create(outPath)
outPath := filepath.Join(workspaceDir, filename)
out, err := os.OpenFile(outPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return "", fmt.Errorf("failed to create output file: %w", err)
}
defer out.Close()
success := false
defer func() {
_ = out.Close()
if !success {
_ = os.Remove(outPath)
}
}()
if contentLength < 0 {
contentLength = 0
}
pw := newProgressWriter(out, contentLength)
defer pw.Stop()
if _, err := io.Copy(pw, part); err != nil {
written, err := io.Copy(pw, io.LimitReader(part, int64(maxPackageSize)+1))
if err != nil {
return "", fmt.Errorf("failed to write file: %w", err)
}
if written > int64(maxPackageSize) {
return "", fmt.Errorf("uploaded package exceeds %d bytes", maxPackageSize)
}
if err := out.Close(); err != nil {
return "", fmt.Errorf("failed to close output file: %w", err)
}
success = true
return outPath, nil
}
@@ -174,6 +281,9 @@ func validateFilename(filename string) error {
log.Warnf("Invalid filename characters: %s", filename)
return fmt.Errorf("invalid filename: contains invalid characters")
}
if !packageNamePattern.MatchString(filename) {
return fmt.Errorf("invalid update package name")
}
return nil
}

View File

@@ -0,0 +1,212 @@
package application
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
const (
UpdateServerConfigFile = "/etc/kvm/application-update.json"
maxUpdateServerURLSize = 2048
)
var (
updateServerConfigMu sync.Mutex
updateServerConfigPath = UpdateServerConfigFile
)
type UpdateServerConfig struct {
Enabled bool `json:"enabled"`
URL string `json:"url"`
}
func defaultUpdateServerConfig() UpdateServerConfig {
return UpdateServerConfig{URL: StableURL}
}
func (s *Service) GetUpdateServer(c *gin.Context) {
var rsp proto.Response
cfg, err := loadUpdateServerConfig()
if err != nil {
log.Errorf("failed to load update server config: %s", err)
rsp.ErrRsp(c, -1, "failed to load update server config")
return
}
rsp.OkRspWithData(c, &proto.GetUpdateServerRsp{
Enabled: cfg.Enabled,
URL: cfg.URL,
})
}
func (s *Service) SetUpdateServer(c *gin.Context) {
var req proto.SetUpdateServerReq
var rsp proto.Response
// Avoid the shared request logger because the URL may contain credentials.
if err := c.ShouldBind(&req); err != nil || req.Enabled == nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
normalizedURL, err := normalizeUpdateServerURL(req.URL)
if err != nil {
rsp.ErrRsp(c, -2, err.Error())
return
}
if *req.Enabled && normalizedURL == "" {
rsp.ErrRsp(c, -2, "update server URL is required")
return
}
if normalizedURL == "" {
normalizedURL = StableURL
}
cfg := UpdateServerConfig{Enabled: *req.Enabled, URL: normalizedURL}
if err := saveUpdateServerConfig(cfg); err != nil {
log.Errorf("failed to save update server config: %s", err)
rsp.ErrRsp(c, -3, "failed to save update server config")
return
}
rsp.OkRspWithData(c, &proto.GetUpdateServerRsp{
Enabled: cfg.Enabled,
URL: cfg.URL,
})
}
func resolveUpdateBaseURL() (string, error) {
cfg, err := loadUpdateServerConfig()
if err != nil {
return "", err
}
if cfg.Enabled {
return cfg.URL, nil
}
if isPreviewEnabled() {
return PreviewURL, nil
}
return StableURL, nil
}
func normalizeUpdateServerURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
if len(raw) > maxUpdateServerURLSize {
return "", errors.New("update server URL is too long")
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return "", errors.New("invalid update server URL")
}
if parsed.RawQuery != "" || parsed.Fragment != "" {
return "", errors.New("update server URL must not contain a query or fragment")
}
if strings.HasSuffix(strings.TrimRight(parsed.Path, "/"), "/latest.json") {
return "", errors.New("enter the update server directory, not latest.json")
}
parsed.Path = strings.TrimRight(parsed.Path, "/")
return parsed.String(), nil
}
func loadUpdateServerConfig() (UpdateServerConfig, error) {
updateServerConfigMu.Lock()
defer updateServerConfigMu.Unlock()
return loadUpdateServerConfigFromPath(updateServerConfigPath)
}
func loadUpdateServerConfigFromPath(path string) (UpdateServerConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return defaultUpdateServerConfig(), nil
}
return UpdateServerConfig{}, err
}
var cfg UpdateServerConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return UpdateServerConfig{}, fmt.Errorf("decode update server config: %w", err)
}
normalizedURL, err := normalizeUpdateServerURL(cfg.URL)
if err != nil {
return UpdateServerConfig{}, err
}
if normalizedURL == "" {
normalizedURL = StableURL
}
cfg.URL = normalizedURL
return cfg, nil
}
func saveUpdateServerConfig(cfg UpdateServerConfig) error {
updateServerConfigMu.Lock()
defer updateServerConfigMu.Unlock()
return saveUpdateServerConfigToPath(updateServerConfigPath, cfg)
}
func saveUpdateServerConfigToPath(path string, cfg UpdateServerConfig) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create update server config directory: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("encode update server config: %w", err)
}
data = append(data, '\n')
tmp, err := os.CreateTemp(dir, ".application-update.json.*")
if err != nil {
return fmt.Errorf("create temporary update server config: %w", err)
}
tmpPath := tmp.Name()
defer func() { _ = os.Remove(tmpPath) }()
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
return fmt.Errorf("set update server config permissions: %w", err)
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write update server config: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("sync update server config: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close update server config: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("replace update server config: %w", err)
}
directory, err := os.Open(dir)
if err == nil {
_ = directory.Sync()
_ = directory.Close()
}
return nil
}

View File

@@ -1,28 +1,46 @@
package application
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
"NanoKVM-Server/proto"
"NanoKVM-Server/utils"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
type Latest struct {
Version string `json:"version"`
Name string `json:"name"`
Sha512 string `json:"sha512"`
Size uint `json:"size"`
Url string `json:"url"`
ManifestVersion int `json:"manifest_version,omitempty"`
Version string `json:"version"`
Name string `json:"name"`
Sha512 string `json:"sha512"`
LegacySize uint64 `json:"size"`
SizeBytes uint64 `json:"size_bytes,omitempty"`
UnpackedSizeBytes uint64 `json:"unpacked_size_bytes,omitempty"`
Url string `json:"-"`
}
const (
maxLatestJSONSize = 64 * 1024
)
var (
latestClient = utils.NewUpdateHTTPClient(15 * time.Second)
packageNamePattern = regexp.MustCompile(`^nanokvm_[0-9]+\.[0-9]+\.[0-9]+\.tar\.gz$`)
versionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`)
)
func (s *Service) GetVersion(c *gin.Context) {
var rsp proto.Response
@@ -39,9 +57,12 @@ func (s *Service) GetVersion(c *gin.Context) {
// latest version
latestVersion := ""
latest, err := getLatest()
if err == nil && latest != nil {
latestVersion = latest.Version
if err != nil {
log.Errorf("failed to get latest version: %s", err)
rsp.ErrRsp(c, -1, "failed to query latest version")
return
}
latestVersion = latest.Version
rsp.OkRspWithData(c, &proto.GetVersionRsp{
Current: currentVersion,
@@ -50,23 +71,37 @@ func (s *Service) GetVersion(c *gin.Context) {
}
func getLatest() (*Latest, error) {
baseURL := StableURL
if isPreviewEnabled() {
baseURL = PreviewURL
baseURL, err := resolveUpdateBaseURL()
if err != nil {
return nil, err
}
url := fmt.Sprintf("%s/latest.json?now=%d", baseURL, time.Now().Unix())
resp, err := http.Get(url)
manifestURL, err := joinUpdateURL(baseURL, "latest.json")
if err != nil {
log.Debugf("failed to request version: %v", err)
return nil, err
}
parsedManifestURL, err := url.Parse(manifestURL)
if err != nil {
return nil, err
}
query := parsedManifestURL.Query()
query.Set("now", fmt.Sprintf("%d", time.Now().Unix()))
parsedManifestURL.RawQuery = query.Encode()
request, err := utils.NewAuthenticatedRequest("GET", parsedManifestURL.String(), nil)
if err != nil {
return nil, err
}
resp, err := latestClient.Do(request)
if err != nil {
log.Debugf("failed to request version from %s", parsedManifestURL.Redacted())
return nil, errors.New("update server is inaccessible")
}
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
body, err := io.ReadAll(io.LimitReader(resp.Body, maxLatestJSONSize+1))
if err != nil {
log.Errorf("failed to read response: %v", err)
return nil, err
@@ -76,15 +111,86 @@ func getLatest() (*Latest, error) {
log.Errorf("server responded with status code: %d", resp.StatusCode)
return nil, fmt.Errorf("status code %d", resp.StatusCode)
}
if len(body) > maxLatestJSONSize {
return nil, fmt.Errorf("latest manifest exceeds %d bytes", maxLatestJSONSize)
}
var latest Latest
if err := json.Unmarshal(body, &latest); err != nil {
log.Errorf("failed to unmarshal response: %s", err)
return nil, err
}
if err := validateLatest(&latest); err != nil {
return nil, err
}
latest.Url = fmt.Sprintf("%s/%s", baseURL, latest.Name)
latest.Url, err = joinUpdateURL(baseURL, latest.Name)
if err != nil {
return nil, err
}
log.Debugf("get application latest version: %s", latest.Version)
return &latest, nil
}
func joinUpdateURL(baseURL string, element string) (string, error) {
joined, err := url.JoinPath(baseURL, element)
if err != nil {
return "", fmt.Errorf("join update URL: %w", err)
}
return joined, nil
}
func validateLatest(latest *Latest) error {
if !versionPattern.MatchString(latest.Version) {
return errors.New("invalid latest version")
}
if !packageNamePattern.MatchString(latest.Name) {
return errors.New("invalid update package name")
}
digest, err := base64.StdEncoding.DecodeString(latest.Sha512)
if err != nil || len(digest) != 64 {
return errors.New("invalid update package sha512")
}
if latest.LegacySize == 0 {
return errors.New("invalid update package size")
}
switch latest.ManifestVersion {
case 0, 1:
return nil
case 2:
if latest.SizeBytes == 0 || latest.SizeBytes > maxPackageSize {
return errors.New("invalid update package size_bytes")
}
if latest.UnpackedSizeBytes == 0 || latest.UnpackedSizeBytes > maxExpandedSize {
return errors.New("invalid update package unpacked_size_bytes")
}
return nil
default:
return errors.New("unsupported update manifest version")
}
}
func preflightManifestSpace(path string, latest *Latest) error {
if latest.ManifestVersion != 2 {
return nil
}
return ensureFreeSpace(path, latest.SizeBytes)
}
func validateDownloadedSize(latest *Latest, written uint64) error {
if written > maxPackageSize {
return fmt.Errorf("update package exceeds %d bytes", maxPackageSize)
}
if latest.ManifestVersion == 2 && written != latest.SizeBytes {
return fmt.Errorf("update package size mismatch: expected %d bytes, got %d", latest.SizeBytes, written)
}
return nil
}
func validateExpandedSize(latest *Latest, expanded uint64) error {
if latest.ManifestVersion == 2 && expanded != latest.UnpackedSizeBytes {
return fmt.Errorf("update package expanded size mismatch: expected %d bytes, got %d", latest.UnpackedSizeBytes, expanded)
}
return nil
}

View File

@@ -0,0 +1,46 @@
package application
import (
"crypto/sha512"
"encoding/base64"
"testing"
)
func validLatest() Latest {
digest := sha512.Sum512([]byte("package"))
return Latest{
Version: "1.2.3", Name: "nanokvm_1.2.3.tar.gz",
Sha512: base64.StdEncoding.EncodeToString(digest[:]), LegacySize: 1,
}
}
func TestValidateLatestV1DoesNotInterpretLegacySizeAsBytes(t *testing.T) {
latest := validLatest()
latest.LegacySize = 15048 // historic stable manifests used a non-byte value
if err := validateLatest(&latest); err != nil {
t.Fatal(err)
}
if err := validateDownloadedSize(&latest, 15406125); err != nil {
t.Fatalf("v1 must not require equality with legacy size: %v", err)
}
}
func TestValidateLatestV2RequiresExactByteFields(t *testing.T) {
latest := validLatest()
latest.ManifestVersion = 2
latest.SizeBytes = 100
latest.UnpackedSizeBytes = 200
if err := validateLatest(&latest); err != nil {
t.Fatal(err)
}
if err := validateDownloadedSize(&latest, 99); err == nil {
t.Fatal("v2 size mismatch was accepted")
}
if err := validateExpandedSize(&latest, 199); err == nil {
t.Fatal("v2 expanded size mismatch was accepted")
}
latest.ManifestVersion = 3
if err := validateLatest(&latest); err == nil {
t.Fatal("unknown manifest version was accepted")
}
}

View File

@@ -0,0 +1,69 @@
package application
import (
"fmt"
"os"
"path/filepath"
"strings"
)
type updateWorkspace struct {
dir string
}
func newUpdateWorkspace(baseDir string) (*updateWorkspace, error) {
if err := os.MkdirAll(baseDir, cacheDirMode); err != nil {
return nil, fmt.Errorf("create update cache: %w", err)
}
if err := os.Chmod(baseDir, cacheDirMode); err != nil {
return nil, fmt.Errorf("chmod update cache: %w", err)
}
dir, err := os.MkdirTemp(baseDir, updateWorkspacePrefix)
if err != nil {
return nil, fmt.Errorf("create update workspace: %w", err)
}
return &updateWorkspace{dir: dir}, nil
}
func (w *updateWorkspace) Close() error {
return os.RemoveAll(w.dir)
}
func cleanupStaleWorkspaces(baseDir string) error {
entries, err := os.ReadDir(baseDir)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), updateWorkspacePrefix) {
continue
}
if err := os.RemoveAll(filepath.Join(baseDir, entry.Name())); err != nil {
return err
}
}
return nil
}
func prepareCacheForUpdate() error {
info, err := os.Stat(AppDir)
if err != nil {
return fmt.Errorf("stat current application: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("current application is not a directory")
}
if err := os.MkdirAll(CacheDir, cacheDirMode); err != nil {
return fmt.Errorf("create update cache: %w", err)
}
if err := os.Chmod(CacheDir, cacheDirMode); err != nil {
return fmt.Errorf("chmod update cache: %w", err)
}
if err := cleanupStaleWorkspaces(CacheDir); err != nil {
return fmt.Errorf("clean stale update workspace: %w", err)
}
return nil
}

View File

@@ -0,0 +1,49 @@
package application
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestWorkspaceCleanupIsScopedToUpdaterDirectories(t *testing.T) {
base := t.TempDir()
for _, name := range []string{"nanokvm-update-old", "update-other", "firmware.tar.xz", "nanokvm-go_1.0.0"} {
if err := os.Mkdir(filepath.Join(base, name), 0o755); err != nil {
t.Fatal(err)
}
}
if err := cleanupStaleWorkspaces(base); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(base, "nanokvm-update-old")); !os.IsNotExist(err) {
t.Fatalf("stale workspace still exists: %v", err)
}
for _, name := range []string{"update-other", "firmware.tar.xz", "nanokvm-go_1.0.0"} {
if _, err := os.Stat(filepath.Join(base, name)); err != nil {
t.Fatalf("unrelated cache entry %s was removed: %v", name, err)
}
}
}
func TestNewWorkspaceIsPrivateAndCloseOnlyRemovesItself(t *testing.T) {
base := t.TempDir()
keep := filepath.Join(base, "keep")
if err := os.WriteFile(keep, []byte("keep"), 0o600); err != nil {
t.Fatal(err)
}
workspace, err := newUpdateWorkspace(base)
if err != nil {
t.Fatal(err)
}
if filepath.Dir(workspace.dir) != base || !strings.HasPrefix(filepath.Base(workspace.dir), updateWorkspacePrefix) {
t.Fatalf("unexpected workspace path %q", workspace.dir)
}
if err := workspace.Close(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(keep); err != nil {
t.Fatalf("unrelated cache entry was removed: %v", err)
}
}

View File

@@ -1,113 +0,0 @@
package auth
import (
"NanoKVM-Server/utils"
"encoding/json"
"errors"
"os"
"path/filepath"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt"
)
const AccountFile = "/etc/kvm/pwd"
type Account struct {
Username string `json:"username"`
Password string `json:"password"` // should be named HashedPassword for clarity
}
func GetAccount() (*Account, error) {
if _, err := os.Stat(AccountFile); err != nil {
if errors.Is(err, os.ErrNotExist) {
return getDefaultAccount(), nil
}
return nil, err
}
content, err := os.ReadFile(AccountFile)
if err != nil {
return nil, err
}
var account Account
if err = json.Unmarshal(content, &account); err != nil {
log.Errorf("unmarshal account failed: %s", err)
return nil, err
}
return &account, nil
}
func SetAccount(username string, hashedPassword string) error {
account, err := json.Marshal(&Account{
Username: username,
Password: hashedPassword,
})
if err != nil {
log.Errorf("failed to marshal account information to json: %s", err)
return err
}
err = os.MkdirAll(filepath.Dir(AccountFile), 0o644)
if err != nil {
log.Errorf("create directory %s failed: %s", AccountFile, err)
return err
}
err = os.WriteFile(AccountFile, account, 0o644)
if err != nil {
log.Errorf("write password failed: %s", err)
return err
}
return nil
}
func CompareAccount(username string, plainPassword string) bool {
account, err := GetAccount()
if err != nil {
return false
}
if username != account.Username {
return false
}
hashedPassword, err := utils.DecodeDecrypt(plainPassword)
if err != nil || hashedPassword == "" {
return false
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(hashedPassword))
if err != nil {
// Compatible with old versions
accountHashedPassword, _ := utils.DecodeDecrypt(account.Password)
if accountHashedPassword == hashedPassword {
return true
}
return false
}
return true
}
func DelAccount() error {
if err := os.Remove(AccountFile); err != nil {
log.Errorf("failed to delete password: %s", err)
return err
}
return nil
}
func getDefaultAccount() *Account {
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
return &Account{
Username: "admin",
Password: string(hashedPassword),
}
}

View File

@@ -1,11 +1,15 @@
package auth
import (
"net/http"
"strings"
"time"
"NanoKVM-Server/authn"
"NanoKVM-Server/config"
"NanoKVM-Server/middleware"
"NanoKVM-Server/proto"
"NanoKVM-Server/utils"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
@@ -15,12 +19,9 @@ func (s *Service) Login(c *gin.Context) {
var req proto.LoginReq
var rsp proto.Response
// authentication disabled
conf := config.GetInstance()
if conf.Authentication == "disable" {
rsp.OkRspWithData(c, &proto.LoginRsp{
Token: "disabled",
})
rsp.OkRsp(c)
return
}
@@ -30,63 +31,99 @@ func (s *Service) Login(c *gin.Context) {
rsp.ErrRsp(c, code, msg)
return
}
if err := proto.ParseFormRequest(c, &req); err != nil {
time.Sleep(3 * time.Second)
rsp.ErrRsp(c, -1, "invalid parameters")
return
}
if ok := CompareAccount(req.Username, req.Password); !ok {
password, err := utils.DecodeDecrypt(req.Password)
if err != nil || password == "" {
s.loginFailed(c, clientIP, &rsp)
return
}
user, ok, err := authn.DefaultStore.Authenticate(req.Username, password)
if err != nil {
log.Errorf("load account during login: %s", err)
time.Sleep(2 * time.Second)
if locked, code, msg := RecordLoginFailure(clientIP); locked {
rsp.ErrRsp(c, code, msg)
return
}
rsp.ErrRsp(c, -2, "invalid username or password")
rsp.ErrRsp(c, -3, "authentication unavailable")
return
}
if !ok {
s.loginFailed(c, clientIP, &rsp)
return
}
ClearLoginAttempt(clientIP)
token, err := middleware.GenerateJWT(req.Username)
token, err := middleware.GenerateJWT(user.Username, user.TokenVersion)
if err != nil {
time.Sleep(1 * time.Second)
time.Sleep(time.Second)
rsp.ErrRsp(c, -3, "generate token failed")
return
}
setSessionCookie(c, token)
rsp.OkRsp(c)
log.Infof("user logged in: %s", user.Username)
}
rsp.OkRspWithData(c, &proto.LoginRsp{
Token: token,
})
log.Debugf("login success, username: %s", req.Username)
func (s *Service) loginFailed(c *gin.Context, clientIP string, rsp *proto.Response) {
time.Sleep(2 * time.Second)
if locked, code, msg := RecordLoginFailure(clientIP); locked {
rsp.ErrRsp(c, code, msg)
return
}
rsp.ErrRsp(c, -2, "invalid username or password")
}
func (s *Service) Logout(c *gin.Context) {
conf := config.GetInstance()
if conf.JWT.RevokeTokensOnLogout {
config.RegenerateSecretKey()
}
var rsp proto.Response
principal, ok := middleware.CurrentPrincipal(c)
conf := config.GetInstance()
if ok && conf.Authentication != "disable" && conf.JWT.RevokeTokensOnLogout {
if _, err := authn.DefaultStore.Revoke(principal.Username); err != nil {
rsp.ErrRsp(c, -1, "failed to revoke session")
return
}
middleware.RevokeUserSessions(principal.Username)
log.Infof("user logged out: %s", principal.Username)
}
clearSessionCookie(c)
rsp.OkRsp(c)
}
func (s *Service) GetAccount(c *gin.Context) {
var rsp proto.Response
account, err := GetAccount()
if err != nil {
principal, ok := middleware.CurrentPrincipal(c)
if !ok {
rsp.ErrRsp(c, -1, "get account failed")
return
}
rsp.OkRspWithData(c, &proto.GetAccountRsp{
Username: account.Username,
Username: principal.Username,
Role: string(principal.Role),
Permissions: permissionStrings(principal.Permissions),
})
log.Debugf("get account successful")
}
func setSessionCookie(c *gin.Context, token string) {
conf := config.GetInstance()
secure := conf.Proto == "https" || c.Request.TLS != nil ||
strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(
middleware.CookieName,
token,
int(conf.JWT.RefreshTokenDuration),
"/",
"",
secure,
true,
)
}
func clearSessionCookie(c *gin.Context) {
conf := config.GetInstance()
secure := conf.Proto == "https" || c.Request.TLS != nil ||
strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(middleware.CookieName, "", -1, "/", "", secure, true)
}

View File

@@ -1,124 +1,126 @@
package auth
import (
"NanoKVM-Server/proto"
"NanoKVM-Server/utils"
"errors"
"io"
"os"
"os/exec"
"time"
"NanoKVM-Server/authn"
"NanoKVM-Server/config"
"NanoKVM-Server/middleware"
"NanoKVM-Server/proto"
"NanoKVM-Server/utils"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt"
)
var systemPasswordUpdater = changeRootPassword
func (s *Service) ChangePassword(c *gin.Context) {
var req proto.ChangePasswordReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid parameters")
return
}
password, err := utils.DecodeDecrypt(req.Password)
if err != nil || password == "" {
rsp.ErrRsp(c, -2, "invalid password")
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
rsp.ErrRsp(c, -3, "failed to hash password")
return
}
if err = SetAccount(req.Username, string(hashedPassword)); err != nil {
rsp.ErrRsp(c, -4, "failed to save password")
return
}
// change root password
err = changeRootPassword(password)
if err != nil {
_ = DelAccount()
rsp.ErrRsp(c, -5, "failed to change password")
principal, ok := middleware.CurrentPrincipal(c)
if !ok {
rsp.ErrRsp(c, -2, "invalid session")
return
}
currentPassword, err := utils.DecodeDecrypt(req.CurrentPassword)
if err != nil || currentPassword == "" {
rsp.ErrRsp(c, -3, "current password is required")
return
}
if _, authenticated, authErr := authn.DefaultStore.Authenticate(principal.Username, currentPassword); authErr != nil {
rsp.ErrRsp(c, -4, "authentication unavailable")
return
} else if !authenticated {
rsp.ErrRsp(c, -3, "current password is incorrect")
return
}
if err := changeUserPassword(principal.Username, req.Password); err != nil {
rsp.ErrRsp(c, -5, err.Error())
return
}
middleware.RevokeUserSessions(principal.Username)
clearSessionCookie(c)
rsp.OkRsp(c)
log.Debugf("change password success, username: %s", req.Username)
log.Infof("password changed for user: %s", principal.Username)
}
func (s *Service) IsPasswordUpdated(c *gin.Context) {
var rsp proto.Response
if config.GetInstance().Authentication == "disable" {
rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{IsUpdated: true})
return
}
principal, ok := middleware.CurrentPrincipal(c)
if !ok {
rsp.ErrRsp(c, -1, "invalid session")
return
}
user, err := authn.DefaultStore.Get(principal.Username)
if err != nil {
rsp.ErrRsp(c, -2, "failed to get password state")
return
}
rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{IsUpdated: !user.MustChangePassword})
}
if _, err := os.Stat(AccountFile); err != nil {
rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{
IsUpdated: false,
func changeUserPassword(username, encryptedPassword string) error {
password, err := utils.DecodeDecrypt(encryptedPassword)
if err != nil || password == "" {
return errInvalidPassword
}
if err = authn.ValidatePassword(password); err != nil {
return err
}
user, err := authn.DefaultStore.Get(username)
if err != nil {
return err
}
if user.SystemAccount && user.Role == authn.RoleAdmin && user.Enabled {
_, err = authn.DefaultStore.SetPasswordAndRun(username, password, func() error {
return systemPasswordUpdater(password)
})
return
return err
}
account, err := GetAccount()
if err != nil || account == nil {
rsp.ErrRsp(c, -1, "failed to get password")
return
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte("admin"))
rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{
// If the hash is not valid, still assume it's not updated
// The error we want to see is password and hash not matching
IsUpdated: errors.Is(err, bcrypt.ErrMismatchedHashAndPassword),
})
_, err = authn.DefaultStore.SetPassword(username, password)
return err
}
func changeRootPassword(password string) error {
err := passwd(password)
if err != nil {
if err := passwd(password); err != nil {
log.Errorf("failed to change root password: %s", err)
return err
}
log.Debugf("change root password successful.")
log.Debug("change root password successful")
return nil
}
func passwd(password string) error {
cmd := exec.Command("passwd", "root")
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
defer func() {
_ = stdin.Close()
}()
defer func() { _ = stdin.Close() }()
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err = cmd.Start(); err != nil {
return err
}
if _, err = io.WriteString(stdin, password+"\n"); err != nil {
return err
}
time.Sleep(100 * time.Millisecond)
if _, err = io.WriteString(stdin, password+"\n"); err != nil {
return err
}
if err = cmd.Wait(); err != nil {
return err
}
return nil
return cmd.Wait()
}

View File

@@ -0,0 +1,307 @@
package auth
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"NanoKVM-Server/authn"
"NanoKVM-Server/config"
"NanoKVM-Server/middleware"
"NanoKVM-Server/proto"
"NanoKVM-Server/utils"
"github.com/gin-gonic/gin"
"github.com/mervick/aes-everywhere/go/aes256"
)
func TestLoginCookieAndUserAuthorizationLifecycle(t *testing.T) {
gin.SetMode(gin.TestMode)
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
restore := useTestStore(store)
defer restore()
service := NewService()
router := gin.New()
router.POST("/login", service.Login)
authenticated := router.Group("/").Use(middleware.CheckToken())
authenticated.GET("/account", service.GetAccount)
authenticated.POST("/logout", service.Logout)
admin := router.Group("/").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
admin.GET("/users", service.ListUsers)
admin.POST("/users", service.CreateUser)
admin.PUT("/users/:username", service.UpdateUser)
adminCookie := loginCookie(t, router, "admin", "admin")
if !adminCookie.HttpOnly || adminCookie.SameSite != http.SameSiteStrictMode || adminCookie.Path != "/" {
t.Fatalf("unsafe session cookie: %+v", adminCookie)
}
createBody := map[string]any{
"username": "alice",
"password": encryptForRequest("valid-password"),
"role": "user",
}
if code := requestJSON(router, http.MethodPost, "/users", createBody, adminCookie); code != http.StatusOK {
t.Fatalf("create status = %d", code)
}
userCookie := loginCookie(t, router, "alice", "valid-password")
if code := requestJSON(router, http.MethodGet, "/users", nil, userCookie); code != http.StatusForbidden {
t.Fatalf("user management status = %d, want 403", code)
}
disabled := false
if code := requestJSON(router, http.MethodPut, "/users/alice", map[string]any{"enabled": disabled}, adminCookie); code != http.StatusOK {
t.Fatalf("disable status = %d", code)
}
if code := requestJSON(router, http.MethodGet, "/account", nil, userCookie); code != http.StatusUnauthorized {
t.Fatalf("disabled session status = %d, want 401", code)
}
}
func TestLogoutDoesNotInvalidateAnotherUser(t *testing.T) {
gin.SetMode(gin.TestMode)
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
restore := useTestStore(store)
defer restore()
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
if err := store.Create("alice", "alice-password", authn.RoleUser); err != nil {
t.Fatal(err)
}
if err := store.Create("bob", "bob-password", authn.RoleUser); err != nil {
t.Fatal(err)
}
service := NewService()
router := gin.New()
router.POST("/login", service.Login)
authenticated := router.Group("/").Use(middleware.CheckToken())
authenticated.GET("/account", service.GetAccount)
authenticated.POST("/logout", service.Logout)
aliceCookie := loginCookie(t, router, "alice", "alice-password")
bobCookie := loginCookie(t, router, "bob", "bob-password")
if code := requestJSON(router, http.MethodPost, "/logout", nil, aliceCookie); code != http.StatusOK {
t.Fatalf("logout status = %d", code)
}
if code := requestJSON(router, http.MethodGet, "/account", nil, aliceCookie); code != http.StatusUnauthorized {
t.Fatalf("logged out session status = %d", code)
}
if code := requestJSON(router, http.MethodGet, "/account", nil, bobCookie); code != http.StatusOK {
t.Fatalf("other user's session status = %d", code)
}
}
func TestNormalUserPasswordNeverChangesSystemPassword(t *testing.T) {
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
restore := useTestStore(store)
defer restore()
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
if err := store.Create("alice", "alice-password", authn.RoleUser); err != nil {
t.Fatal(err)
}
originalUpdater := systemPasswordUpdater
updates := 0
systemPasswordUpdater = func(string) error {
updates++
return nil
}
defer func() { systemPasswordUpdater = originalUpdater }()
if err := changeUserPassword("alice", encryptForRequest("new-password")); err != nil {
t.Fatal(err)
}
if updates != 0 {
t.Fatalf("system password updated %d times for a normal user", updates)
}
if _, ok, err := store.Authenticate("alice", "new-password"); err != nil || !ok {
t.Fatalf("new web password login: ok=%v err=%v", ok, err)
}
}
func TestSelfPasswordChangeRequiresCurrentPassword(t *testing.T) {
gin.SetMode(gin.TestMode)
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
restore := useTestStore(store)
defer restore()
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
if err := store.Create("alice", "alice-password", authn.RoleUser); err != nil {
t.Fatal(err)
}
service := NewService()
router := gin.New()
router.POST("/login", service.Login)
authenticated := router.Group("/").Use(middleware.CheckToken())
authenticated.POST("/password", service.ChangePassword)
cookie := loginCookie(t, router, "alice", "alice-password")
recorder := requestJSONRecorder(router, http.MethodPost, "/password", map[string]any{
"currentPassword": encryptForRequest("wrong-password"),
"password": encryptForRequest("new-password"),
}, cookie)
var response proto.Response
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode wrong-current-password response: %v", err)
}
if response.Code != -3 || response.Msg != "current password is incorrect" {
t.Fatalf("wrong-current-password response = %+v", response)
}
if _, ok, err := store.Authenticate("alice", "new-password"); err != nil || ok {
t.Fatalf("password changed without current credential: ok=%v err=%v", ok, err)
}
requestJSON(router, http.MethodPost, "/password", map[string]any{
"currentPassword": encryptForRequest("alice-password"),
"password": encryptForRequest("new-password"),
}, cookie)
if _, ok, err := store.Authenticate("alice", "new-password"); err != nil || !ok {
t.Fatalf("valid password change failed: ok=%v err=%v", ok, err)
}
}
func TestAdministratorCannotResetDeviceOwnerPassword(t *testing.T) {
gin.SetMode(gin.TestMode)
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
restore := useTestStore(store)
defer restore()
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
if err := store.Create("second-admin", "second-password", authn.RoleAdmin); err != nil {
t.Fatal(err)
}
originalUpdater := systemPasswordUpdater
updates := 0
systemPasswordUpdater = func(string) error {
updates++
return nil
}
defer func() { systemPasswordUpdater = originalUpdater }()
service := NewService()
router := gin.New()
router.POST("/login", service.Login)
admin := router.Group("/").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
admin.POST("/users/:username/password", service.ChangeUserPassword)
cookie := loginCookie(t, router, "second-admin", "second-password")
requestJSON(router, http.MethodPost, "/users/admin/password", map[string]any{
"password": encryptForRequest("new-password"),
}, cookie)
if updates != 0 {
t.Fatalf("administrator changed the device owner's system password %d times", updates)
}
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("device owner password changed: ok=%v err=%v", ok, err)
}
if _, ok, err := store.Authenticate("admin", "new-password"); err != nil || ok {
t.Fatalf("unauthorized password became active: ok=%v err=%v", ok, err)
}
}
func TestSystemPasswordFailureRollsBackWebPassword(t *testing.T) {
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
restore := useTestStore(store)
defer restore()
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
originalUpdater := systemPasswordUpdater
systemPasswordUpdater = func(string) error { return errors.New("passwd failed") }
defer func() { systemPasswordUpdater = originalUpdater }()
if err := changeUserPassword("admin", encryptForRequest("new-password")); err == nil {
t.Fatal("system password failure was ignored")
}
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("old web password was not restored: ok=%v err=%v", ok, err)
}
if _, ok, err := store.Authenticate("admin", "new-password"); err != nil || ok {
t.Fatalf("failed password stayed active: ok=%v err=%v", ok, err)
}
}
func loginCookie(t *testing.T, handler http.Handler, username, password string) *http.Cookie {
t.Helper()
body := map[string]any{"username": username, "password": encryptForRequest(password)}
recorder := httptest.NewRecorder()
request := jsonRequest(http.MethodPost, "/login", body)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("login status = %d", recorder.Code)
}
if bytes.Contains(recorder.Body.Bytes(), []byte("token")) {
t.Fatalf("login exposed JWT in response: %s", recorder.Body.String())
}
for _, cookie := range recorder.Result().Cookies() {
if cookie.Name == middleware.CookieName {
return cookie
}
}
t.Fatal("login did not set session cookie")
return nil
}
func requestJSON(handler http.Handler, method, path string, body any, cookie *http.Cookie) int {
return requestJSONRecorder(handler, method, path, body, cookie).Code
}
func requestJSONRecorder(handler http.Handler, method, path string, body any, cookie *http.Cookie) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
request := jsonRequest(method, path, body)
if cookie != nil {
request.AddCookie(cookie)
}
handler.ServeHTTP(recorder, request)
return recorder
}
func jsonRequest(method, path string, body any) *http.Request {
var data []byte
if body != nil {
data, _ = json.Marshal(body)
}
request := httptest.NewRequest(method, path, bytes.NewReader(data))
request.Header.Set("Content-Type", "application/json")
return request
}
func encryptForRequest(password string) string {
return url.QueryEscape(aes256.Encrypt(password, utils.SecretKey))
}
func useTestStore(store *authn.Store) func() {
originalStore := authn.DefaultStore
conf := config.GetInstance()
originalAuthentication := conf.Authentication
originalSecret := conf.JWT.SecretKey
originalDuration := conf.JWT.RefreshTokenDuration
originalRevokeOnLogout := conf.JWT.RevokeTokensOnLogout
authn.DefaultStore = store
conf.Authentication = "enable"
conf.JWT.SecretKey = "test-secret"
conf.JWT.RefreshTokenDuration = 3600
conf.JWT.RevokeTokensOnLogout = true
return func() {
authn.DefaultStore = originalStore
conf.Authentication = originalAuthentication
conf.JWT.SecretKey = originalSecret
conf.JWT.RefreshTokenDuration = originalDuration
conf.JWT.RevokeTokensOnLogout = originalRevokeOnLogout
}
}

View File

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

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

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

@@ -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

@@ -7,6 +7,7 @@ import (
"sync"
"time"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/stream/mjpeg"
@@ -19,9 +20,7 @@ import (
var gatewayUpgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool {
return true
},
CheckOrigin: middleware.CheckWebSocketOrigin,
}
type relayResult struct {
@@ -88,6 +87,8 @@ func (s *Service) ConnectGateway(c *gin.Context) {
GetSessionManager().Remove(sessionID)
return
}
stopSessionWatcher := middleware.WatchWebSocket(c.Request.Context(), downstream)
defer stopSessionWatcher()
GetSessionManager().AttachUpstream(sessionID, upstream)
GetSessionManager().AttachDownstream(sessionID, downstream)

View File

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

View File

@@ -0,0 +1,64 @@
package storage
import (
"errors"
"fmt"
"os/exec"
"golang.org/x/sys/unix"
)
// Only one side may write the data partition at a time. The emulated USB disk
// shares that partition with the target machine as a raw, writable disk, and
// while it does the target machine is its only writer, so /data is remounted
// read-only here: remounting keeps every writer on this side away from the
// filesystem, not just the file manager that checks openMedium first.
// Mounting an image read-only hands the partition back to this side, and so
// does releasing the medium. Every read-only to writable transition drops the
// cache built while the target owned the partition before writers are allowed
// back in; callers must not remount it writable first and clean up afterwards.
func SetDataWritable(writable bool) error {
var stat unix.Statfs_t
if err := unix.Statfs(mediaDirectory, &stat); err != nil {
return fmt.Errorf("stat %s: %w", mediaDirectory, err)
}
if (stat.Flags&unix.ST_RDONLY == 0) == writable {
return nil
}
option := "rw"
if !writable {
option = "ro"
} else {
// The target may have changed both file data and allocation metadata
// while it owned the block device. Invalidate that view before the
// filesystem becomes writable, on every path that hands it back.
// A remount does not recount exFAT's used clusters (counted only at
// mount), so free space stays stale and freeing what the target wrote
// can underflow that count until /data is mounted afresh.
dropAllPartitionCaches()
}
if err := exec.Command("mount", "-o", "remount,"+option, mediaDirectory).Run(); err != nil {
return fmt.Errorf("remount %s %s: %w", mediaDirectory, option, err)
}
// Never leave a writable /data behind for a target machine that is writing
// the same filesystem.
var check unix.Statfs_t
if err := unix.Statfs(mediaDirectory, &check); err != nil {
return fmt.Errorf("stat %s: %w", mediaDirectory, err)
}
if (check.Flags&unix.ST_RDONLY == 0) != writable {
return errors.New(mediaDirectory + " mount did not change")
}
return nil
}
// DataDiskAttached reports whether the emulated disk serves the whole data
// partition to the target machine, which is the state /data is read-only in.
func DataDiskAttached() bool {
return IsAttached(partitionDevice)
}

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,130 @@
package diskfs
import (
"io"
"os"
"path/filepath"
"testing"
)
func writeImage(t *testing.T, descriptors ...string) string {
t.Helper()
image := make([]byte, 0x8000+(len(descriptors)+2)*opticalSectorSize)
for i, id := range descriptors {
copy(image[0x8000+i*opticalSectorSize+1:], id)
}
path := filepath.Join(t.TempDir(), "disc.iso")
if err := os.WriteFile(path, image, 0o644); err != nil {
t.Fatal(err)
}
return path
}
func TestDetectOptical(t *testing.T) {
tests := []struct {
name string
descriptors []string
want Kind
}{
{"iso9660", []string{"CD001", "CD001"}, KindISO9660},
{"iso9660 with udf bridge", []string{"CD001", "BEA01", "NSR02", "TEA01"}, KindISO9660},
{"pure udf 1.02", []string{"BEA01", "NSR02", "TEA01"}, KindUDF},
{"pure udf 2.x", []string{"BEA01", "NSR03", "TEA01"}, KindUDF},
{"extended area without nsr", []string{"BEA01", "TEA01"}, KindUnknown},
{"nothing", nil, KindUnknown},
{"nsr after a gap", []string{"BEA01", "", "NSR03"}, KindUnknown},
}
for _, tt := range tests {
kind, err := Detect(writeImage(t, tt.descriptors...))
if err != nil {
t.Errorf("%s: %v", tt.name, err)
continue
}
if kind != tt.want {
t.Errorf("%s: Detect = %q, want %q", tt.name, kind, tt.want)
}
if kind.Optical() != (tt.want == KindISO9660 || tt.want == KindUDF) {
t.Errorf("%s: Optical = %v", tt.name, kind.Optical())
}
}
}
func TestIsTransferTemp(t *testing.T) {
tests := map[string]bool{
".kvm-upload-0a1b2c3d": true,
".kvm-upload-00000000": true,
".kvm-upload-0A1B2C3D": false,
".kvm-upload-0a1b2c3": false,
".kvm-upload-notes.txt": false,
".nanokvm-upload-123456789": true,
".nanokvm-download-4294967295": true,
".nanokvm-download-": false,
".nanokvm-config": false,
".nanokvm-upload-12ab": false,
"ubuntu.iso": false,
}
for name, want := range tests {
if got := IsTransferTemp(name); got != want {
t.Errorf("IsTransferTemp(%q) = %v, want %v", name, got, want)
}
}
}
func TestListDirHidesTransferTemps(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"a.txt", ".kvm-upload-0a1b2c3d", ".nanokvm-download-42", ".nanokvm-mine"} {
if err := os.WriteFile(filepath.Join(root, name), nil, 0o644); err != nil {
t.Fatal(err)
}
}
entries, err := listDir(root, "/")
if err != nil {
t.Fatal(err)
}
var names []string
for _, entry := range entries {
names = append(names, entry.Name)
}
if len(names) != 2 || names[0] != ".nanokvm-mine" || names[1] != "a.txt" {
t.Errorf("listDir = %v, want [.nanokvm-mine a.txt]", names)
}
}
func TestOpenFileRefusesSymlinks(t *testing.T) {
outside := filepath.Join(t.TempDir(), "secret")
if err := os.WriteFile(outside, []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
root := t.TempDir()
if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "dir"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "file"), []byte("ok"), 0o644); err != nil {
t.Fatal(err)
}
if file, _, err := openFile(root, "link"); err == nil {
_ = file.Close()
t.Error("openFile followed a symlink")
}
if file, _, err := openFile(root, "dir"); err == nil {
_ = file.Close()
t.Error("openFile opened a directory")
}
file, entry, err := openFile(root, "file")
if err != nil {
t.Fatal(err)
}
defer file.Close()
data, _ := io.ReadAll(file)
if string(data) != "ok" || entry.Size != 2 {
t.Errorf("openFile read %q, size %d", data, entry.Size)
}
}

View File

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

View File

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

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

@@ -2,56 +2,41 @@ package storage
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"NanoKVM-Server/proto"
"NanoKVM-Server/service/hid"
"NanoKVM-Server/service/storage/diskfs"
"NanoKVM-Server/utils"
)
const (
imageDirectory = "/data"
imageNone = "/dev/mmcblk0p3"
cdromFlag = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/cdrom"
mountDevice = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/file"
inquiryString = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/inquiry_string"
roFlag = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/ro"
)
// The handlers in this file are the pre-2.6 image API. The web UI uses the
// media API instead, but these routes stay so scripts written against the
// documented endpoints keep working: same paths, same request shapes, same
// responses. They share the media implementation underneath, which is also
// where the guards they used to lack now live.
func (s *Service) GetImages(c *gin.Context) {
var rsp proto.Response
var images []string
err := filepath.Walk(imageDirectory, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
name := strings.ToLower(info.Name())
if strings.HasSuffix(name, ".iso") || strings.HasSuffix(name, ".img") {
images = append(images, path)
}
}
return nil
})
media, err := listMedia()
if err != nil {
rsp.ErrRsp(c, -2, "get images failed")
respondError(c, err)
return
}
rsp.OkRspWithData(c, &proto.GetImagesRsp{
Files: images,
})
log.Debugf("get images success, total %d", len(images))
files := make([]string, 0, len(media))
for _, item := range media {
if item.Type == proto.MediaTypeImage {
files = append(files, filepath.Join(mediaDirectory, filepath.FromSlash(item.Rel)))
}
}
rsp.OkRspWithData(c, &proto.GetImagesRsp{Files: files})
log.Debugf("get images success, total %d", len(files))
}
func (s *Service) MountImage(c *gin.Context) {
@@ -59,88 +44,41 @@ func (s *Service) MountImage(c *gin.Context) {
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
return
}
// cdrom and ro flag
// set to 0 when unmount image
// set to 1 when mount image and the CD-ROM is enabled
if req.File == "" || req.Cdrom {
flag := "0"
if req.File != "" && req.Cdrom {
flag = "1"
}
// unmount
if err := os.WriteFile(mountDevice, []byte("\n"), 0o666); err != nil {
log.Errorf("unmount file failed: %s", err)
rsp.ErrRsp(c, -2, "unmount image failed")
return
}
// ro flag
if err := os.WriteFile(roFlag, []byte(flag), 0o666); err != nil {
log.Errorf("set ro flag failed: %s", err)
rsp.ErrRsp(c, -2, "set ro flag failed")
return
}
// cdrom flag
if err := os.WriteFile(cdromFlag, []byte(flag), 0o666); err != nil {
log.Errorf("set cdrom flag failed: %s", err)
rsp.ErrRsp(c, -2, "set cdrom flag failed")
return
}
}
inquiryVen := "NanoKVM"
inquiryPrd := "USB Mass Storage"
inquiryVer := 0x0520
if req.Cdrom {
inquiryPrd = "USB CD/DVD-ROM"
}
inquiryData := fmt.Sprintf("%-8s%-16s%04x", inquiryVen, inquiryPrd, inquiryVer)
if err := os.WriteFile(inquiryString, []byte(inquiryData), 0o666); err != nil {
log.Errorf("set inquiry %s failed: %s", inquiryData, err)
rsp.ErrRsp(c, -2, "set inquiry failed")
return
}
// mount
image := req.File
if image == "" {
image = imageNone
}
if err := os.WriteFile(mountDevice, []byte(image), 0o666); err != nil {
log.Errorf("mount file %s failed: %s", image, err)
rsp.ErrRsp(c, -2, "mount image failed")
return
}
h := hid.GetHid()
h.Lock()
h.CloseNoLock()
defer func() {
h.OpenNoLock()
h.Unlock()
}()
// reset usb
commands := []string{
"echo > /sys/kernel/config/usb_gadget/g0/UDC",
"ls /sys/class/udc/ | cat > /sys/kernel/config/usb_gadget/g0/UDC",
}
for _, command := range commands {
err := exec.Command("sh", "-c", command).Run()
// An empty file keeps its original meaning: attach the whole data
// partition, which is what the emulated disk exposes by default.
backing := partitionDevice
if req.File != "" {
rel, err := legacyImageRel(req.File)
if err != nil {
rsp.ErrRsp(c, -2, "execute command failed")
respondError(c, err)
return
}
time.Sleep(100 * time.Millisecond)
backing, err = utils.ResolveMediaID(rel)
if err != nil {
respondError(c, err)
return
}
}
if req.Cdrom {
kind, err := diskfs.Detect(backing)
if err != nil || !kind.Optical() {
respondError(c, fmt.Errorf("%w: %s", errNotOptical, req.File))
return
}
}
// An image is always served read-only, the raw partition is not.
ro := req.Cdrom || req.File != ""
if err := s.attachMedium(backing, req.Cdrom, ro, false); err != nil {
respondError(c, err)
return
}
rsp.OkRsp(c)
@@ -150,58 +88,37 @@ func (s *Service) MountImage(c *gin.Context) {
func (s *Service) GetMountedImage(c *gin.Context) {
var rsp proto.Response
mode, err := hid.GetMode()
status, err := readDiskStatus()
if err != nil {
rsp.ErrRsp(c, -2, "get HID mode failed")
rsp.ErrRsp(c, codeOperationFailed, "read failed")
return
}
if mode == hid.ModeHidOnly {
rsp.OkRspWithData(c, &proto.GetMountedImageRsp{
File: "",
})
return
// The data partition is reported as "nothing mounted", the way this
// endpoint has always answered.
file := ""
if status.State == diskStateImage {
file = status.Backing
}
content, err := os.ReadFile(mountDevice)
if err != nil {
rsp.ErrRsp(c, -2, "read failed")
return
}
image := strings.ReplaceAll(string(content), "\n", "")
if image == imageNone {
image = ""
}
data := &proto.GetMountedImageRsp{
File: image,
}
rsp.OkRspWithData(c, data)
rsp.OkRspWithData(c, &proto.GetMountedImageRsp{File: file})
}
func (s *Service) GetCdRom(c *gin.Context) {
var rsp proto.Response
content, err := os.ReadFile(cdromFlag)
cdrom, err := readLunBool(lunCDRomFlag)
if err != nil {
rsp.ErrRsp(c, -1, "read failed")
rsp.ErrRsp(c, codeOperationFailed, "read failed")
return
}
flag := strings.ReplaceAll(string(content), "\n", "")
flatInt, err := strconv.ParseInt(flag, 10, 64)
if err != nil {
rsp.ErrRsp(c, -2, "parse failed")
return
flag := int64(0)
if cdrom {
flag = 1
}
data := &proto.GetCdRomRsp{
Cdrom: flatInt,
}
rsp.OkRspWithData(c, data)
rsp.OkRspWithData(c, &proto.GetCdRomRsp{Cdrom: flag})
}
func (s *Service) DeleteImage(c *gin.Context) {
@@ -209,25 +126,45 @@ func (s *Service) DeleteImage(c *gin.Context) {
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
return
}
filename := strings.ToLower(req.File)
validPrefix := strings.HasPrefix(filename, imageDirectory)
validSuffix := strings.HasSuffix(filename, ".iso") || strings.HasSuffix(filename, ".img")
if !validPrefix || !validSuffix {
rsp.ErrRsp(c, -2, "invalid arguments")
rel, err := legacyImageRel(req.File)
if err != nil {
respondError(c, err)
return
}
if err := os.Remove(req.File); err != nil {
rsp.ErrRsp(c, -3, "remove file failed")
log.Errorf("failed to remove file %s: %s", req.File, err)
target, err := utils.ResolveMediaID(rel)
if err != nil {
respondError(c, err)
return
}
if err := deleteMedium(target); err != nil {
log.Errorf("failed to remove file %s: %s", target, err)
respondError(c, err)
return
}
rsp.OkRsp(c)
log.Debugf("delete image %s success", req.File)
}
// legacyImageRel turns the absolute path this API has always used into a
// medium id. The old validation was a string prefix check, which also accepted
// "/dataevil.img" and "/data/../../etc/passwd.iso"; resolving the path
// properly closes both.
func legacyImageRel(file string) (string, error) {
if !filepath.IsAbs(file) {
return "", fmt.Errorf("%w: %s is not an absolute path", utils.ErrInvalidMediaID, file)
}
rel, err := filepath.Rel(mediaDirectory, filepath.Clean(file))
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("%w: %s is outside %s", utils.ErrInvalidMediaID, file, mediaDirectory)
}
return rel, nil
}

View File

@@ -0,0 +1,42 @@
package storage
import "testing"
func TestImageName(t *testing.T) {
tests := []struct {
file string
want string
}{
{"/data/ubuntu.iso", "ubuntu"},
{"/data/win11.img", "win11"},
{"/data/a.b.c.iso", "a.b.c"},
{"/data/no-extension", "no-extension"},
}
for _, tt := range tests {
if got := imageName(tt.file); got != tt.want {
t.Errorf("imageName(%q) = %q, want %q", tt.file, got, tt.want)
}
}
}
func TestCutName(t *testing.T) {
tests := []struct {
name string
size int
want string
}{
{"ubuntu", 16, "ubuntu"},
{"Install_macOS_27_Golden_Gate", 16, "Install_macOS_27"},
{"Install_macOS_27_Golden_Gate", 126, "Install_macOS_27_Golden_Gate"},
// 16 bytes is 5 three byte runes and 1 spare byte
{"中文安装镜像文件系统", 16, "中文安装镜"},
{"中文安装镜像文件系统", 126, "中文安装镜像文件系统"},
}
for _, tt := range tests {
if got := cutName(tt.name, tt.size); got != tt.want {
t.Errorf("cutName(%q, %d) = %q, want %q", tt.name, tt.size, got, tt.want)
}
}
}

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

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

@@ -1,8 +1,8 @@
package direct
import (
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/stream"
"net/http"
"strconv"
"time"
@@ -17,9 +17,7 @@ var (
streamer = newStreamer()
upgrader = websocket.Upgrader{
WriteBufferSize: 256 * 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
CheckOrigin: middleware.CheckWebSocketOrigin,
}
)
@@ -29,6 +27,8 @@ func Connect(c *gin.Context) {
log.Errorf("failed to upgrade to websocket: %s", err)
return
}
stopSessionWatcher := middleware.WatchWebSocket(c.Request.Context(), ws)
defer stopSessionWatcher()
client := newClient(ws)
if flowWindow, err := strconv.Atoi(c.Query("flow")); err == nil && flowWindow > 0 {
client.queue.enableFlowControl(flowWindow)

View File

@@ -1,12 +1,10 @@
package direct
import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"NanoKVM-Server/service/vm"
"sync"
"sync/atomic"
"time"
log "github.com/sirupsen/logrus"
)
@@ -87,17 +85,14 @@ func (s *Streamer) getClients() []*client {
}
func (s *Streamer) run() {
screen := common.GetScreen()
common.CheckScreen()
fps := screen.FPS
subscription := stream.SubscribeH264()
defer subscription.Close()
ticker := time.NewTicker(time.Second / time.Duration(fps))
defer ticker.Stop()
vision := common.GetKvmVision()
startTime := time.Now()
for range ticker.C {
for {
frame, ok := subscription.Next()
if !ok {
return
}
clients := s.getClients()
if len(clients) == 0 {
if s.stopIfIdle() {
@@ -107,28 +102,19 @@ func (s *Streamer) run() {
continue
}
if screen.FPS != fps && screen.FPS != 0 {
fps = screen.FPS
ticker.Reset(time.Second / time.Duration(fps))
}
if !hasCaptureDemand(clients) {
continue
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
stream.UpdateCaptureStatus(stream.CaptureModeDirect, result)
if result < 0 || len(data) == 0 {
stream.UpdateCaptureStatus(stream.CaptureModeDirect, frame.Result)
if frame.Result < 0 || len(frame.Data) == 0 {
continue
}
timestamp := time.Since(startTime).Microseconds()
frame := newOutboundFrame(result == 3, timestamp, data)
outbound := newOutboundFrame(frame.Result == 3, frame.Timestamp, frame.Data)
for _, client := range clients {
client.offer(frame)
client.offer(outbound)
}
stream.GetFrameRateCounter().Update()
}
}

View File

@@ -0,0 +1,185 @@
package stream
import (
"NanoKVM-Server/common"
"sync"
"sync/atomic"
"time"
)
type H264Frame struct {
Data []byte
Result int
Duration time.Duration
Timestamp int64
}
type H264Subscription struct {
frames chan H264Frame
done chan struct{}
closed atomic.Bool
once sync.Once
waitingForKeyframe bool
}
type H264Source struct {
mutex sync.Mutex
subscribers map[*H264Subscription]struct{}
running bool
}
var defaultH264Source = &H264Source{
subscribers: make(map[*H264Subscription]struct{}),
}
func SubscribeH264() *H264Subscription {
return defaultH264Source.subscribe()
}
func (s *H264Source) subscribe() *H264Subscription {
subscription := &H264Subscription{
frames: make(chan H264Frame, 4),
done: make(chan struct{}),
}
s.mutex.Lock()
s.subscribers[subscription] = struct{}{}
start := !s.running
if start {
s.running = true
}
s.mutex.Unlock()
if start {
go s.run()
}
return subscription
}
func (s *H264Subscription) Next() (H264Frame, bool) {
select {
case frame := <-s.frames:
return frame, true
case <-s.done:
return H264Frame{}, false
}
}
func (s *H264Subscription) Close() {
s.once.Do(func() {
s.closed.Store(true)
close(s.done)
defaultH264Source.remove(s)
})
}
func (s *H264Source) remove(subscription *H264Subscription) {
s.mutex.Lock()
delete(s.subscribers, subscription)
s.mutex.Unlock()
}
func (s *H264Source) run() {
screen := common.GetScreen()
common.CheckScreen()
fps := screen.FPS
ticker := time.NewTicker(time.Second / time.Duration(fps))
defer ticker.Stop()
vision := common.GetKvmVision()
startTime := time.Now()
for range ticker.C {
subscribers := s.snapshot()
if len(subscribers) == 0 {
s.mutex.Lock()
if len(s.subscribers) == 0 {
s.running = false
s.mutex.Unlock()
return
}
s.mutex.Unlock()
continue
}
if screen.FPS != fps && screen.FPS != 0 {
fps = screen.FPS
ticker.Reset(time.Second / time.Duration(fps))
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
if result < 0 {
frame := H264Frame{Result: result}
for _, subscription := range subscribers {
subscription.send(frame)
}
continue
}
if len(data) == 0 {
continue
}
frame := H264Frame{
Data: data,
Result: result,
Duration: time.Second / time.Duration(fps),
Timestamp: time.Since(startTime).Microseconds(),
}
for _, subscription := range subscribers {
subscription.send(frame)
}
GetFrameRateCounter().Update()
}
}
func (s *H264Source) snapshot() []*H264Subscription {
s.mutex.Lock()
defer s.mutex.Unlock()
subscribers := make([]*H264Subscription, 0, len(s.subscribers))
for subscription := range s.subscribers {
subscribers = append(subscribers, subscription)
}
return subscribers
}
func (s *H264Subscription) send(frame H264Frame) bool {
if s.closed.Load() {
return false
}
if s.waitingForKeyframe {
if frame.Result != 3 {
return false
}
s.waitingForKeyframe = false
}
select {
case s.frames <- frame:
return true
case <-s.done:
return false
default:
}
for {
select {
case <-s.frames:
case <-s.done:
return false
default:
s.waitingForKeyframe = true
if frame.Result != 3 {
return false
}
s.waitingForKeyframe = false
select {
case s.frames <- frame:
return true
case <-s.done:
return false
}
}
}
}

View File

@@ -2,17 +2,16 @@ package webrtc
import (
"encoding/json"
"errors"
"github.com/gorilla/websocket"
"github.com/pion/rtp"
"github.com/pion/rtp/codecs"
"github.com/pion/webrtc/v4"
log "github.com/sirupsen/logrus"
"sync"
)
const h264SDPFmtpLine = "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f"
func NewClient(ws *websocket.Conn, videoConn *webrtc.PeerConnection) *Client {
return &Client{
ws: ws,
@@ -72,7 +71,11 @@ func (c *Client) ReadMessage() (*Message, error) {
func (c *Client) AddTrack() error {
// video track
videoTrack, err := webrtc.NewTrackLocalStaticRTP(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeH264,
ClockRate: 90000,
SDPFmtpLine: h264SDPFmtpLine,
},
"video",
"pion-video",
)
@@ -81,20 +84,6 @@ func (c *Client) AddTrack() error {
return err
}
videoPacketizer := rtp.NewPacketizer(
1200,
100,
0x1234ABCD,
&codecs.H264Payloader{},
rtp.NewRandomSequencer(),
90000,
)
if videoPacketizer == nil {
err := errors.New("failed to create rtp packetizer")
log.Error(err)
return err
}
videoSender, err := c.video.AddTrack(videoTrack)
if err != nil {
log.Errorf("failed to add video track: %s", err)
@@ -103,10 +92,8 @@ func (c *Client) AddTrack() error {
go startRTCPReader(videoSender)
track := &Track{
videoPacketizer: videoPacketizer,
video: videoTrack,
video: videoTrack,
}
track.updateExtension()
c.mutex.Lock()
c.track = track

View File

@@ -2,14 +2,15 @@ package webrtc
import (
"NanoKVM-Server/config"
"NanoKVM-Server/middleware"
"encoding/json"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/pion/dtls/v3"
"github.com/pion/interceptor"
"github.com/pion/webrtc/v4"
log "github.com/sirupsen/logrus"
)
@@ -17,9 +18,7 @@ import (
var (
upgrader = websocket.Upgrader{
WriteBufferSize: 256 * 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
CheckOrigin: middleware.CheckWebSocketOrigin,
}
globalManager *WebRTCManager
managerOnce sync.Once
@@ -39,6 +38,8 @@ func Connect(c *gin.Context) {
log.Errorf("failed to create h264 websocket: %s", err)
return
}
stopSessionWatcher := middleware.WatchWebSocket(c.Request.Context(), wsConn)
defer stopSessionWatcher()
defer func() {
_ = wsConn.Close()
log.Debugf("h264 websocket disconnected: %s", c.ClientIP())
@@ -151,14 +152,6 @@ func createMediaEngine() (*webrtc.MediaEngine, error) {
return nil, err
}
if err := mediaEngine.RegisterHeaderExtension(
webrtc.RTPHeaderExtensionCapability{URI: "http://www.webrtc.org/experiments/rtp-hdrext/playout-delay"},
webrtc.RTPCodecTypeVideo,
); err != nil {
log.Errorf("failed to register header extension: %s", err)
return nil, err
}
return mediaEngine, nil
}
@@ -173,6 +166,14 @@ func createPeerConnection(iceServers []webrtc.ICEServer, mediaEngine *webrtc.Med
webrtc.WithSettingEngine(settingEngine),
}
if mediaEngine != nil {
registry := &interceptor.Registry{}
if err := webrtc.ConfigureNack(mediaEngine, registry); err != nil {
return nil, err
}
if err := webrtc.ConfigureRTCPReports(registry); err != nil {
return nil, err
}
apiOptions = append(apiOptions, webrtc.WithInterceptorRegistry(registry))
apiOptions = append(apiOptions, webrtc.WithMediaEngine(mediaEngine))
}

View File

@@ -1,19 +1,27 @@
package webrtc
import (
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream"
"NanoKVM-Server/service/vm"
"time"
"github.com/gorilla/websocket"
"github.com/pion/rtp"
"github.com/pion/rtp/codecs"
"github.com/pion/webrtc/v4/pkg/media"
log "github.com/sirupsen/logrus"
)
func NewWebRTCManager() *WebRTCManager {
m := &WebRTCManager{
clients: make(map[*websocket.Conn]*Client),
clients: make(map[*websocket.Conn]*Client),
videoPacketizer: rtp.NewPacketizer(
1200,
100,
0x1234ABCD,
&codecs.H264Payloader{},
rtp.NewRandomSequencer(),
90000,
),
videoSending: false,
}
m.updateClientSnapshotLocked()
@@ -22,8 +30,6 @@ func NewWebRTCManager() *WebRTCManager {
}
func (m *WebRTCManager) AddClient(ws *websocket.Conn, client *Client) {
client.track.updateExtension()
m.mutex.Lock()
m.clients[ws] = client
count := m.updateClientSnapshotLocked()
@@ -96,52 +102,62 @@ func (m *WebRTCManager) stopVideoStreamIfIdle() bool {
}
func (m *WebRTCManager) sendVideoStream() {
screen := common.GetScreen()
common.CheckScreen()
fps := screen.FPS
duration := time.Second / time.Duration(fps)
subscription := stream.SubscribeH264()
defer subscription.Close()
samples, writerDone := m.startVideoWriter()
vision := common.GetKvmVision()
ticker := time.NewTicker(duration)
defer ticker.Stop()
for range ticker.C {
for {
frame, ok := subscription.Next()
if !ok {
close(samples)
<-writerDone
return
}
clients := m.getClients()
if len(clients) == 0 {
close(samples)
<-writerDone
if m.stopVideoStreamIfIdle() {
log.Debugf("stop sending h264 stream")
return
}
samples, writerDone = m.startVideoWriter()
continue
}
data, result := vision.ReadH264(screen.Width, screen.Height, screen.BitRate)
stream.UpdateCaptureStatus(stream.CaptureModeH264, result)
if result < 0 || len(data) == 0 {
stream.UpdateCaptureStatus(stream.CaptureModeH264, frame.Result)
if frame.Result < 0 || len(frame.Data) == 0 {
continue
}
sample := media.Sample{
Data: data,
Duration: duration,
Data: frame.Data,
Duration: frame.Duration,
}
for _, client := range clients {
if err := client.track.writeVideoSample(sample); err != nil {
log.Errorf("failed to write h264 video to client: %s", err)
m.RemoveClient(client.WsConn())
client.Close()
}
}
if screen.FPS != fps && screen.FPS != 0 {
fps = screen.FPS
duration = time.Second / time.Duration(fps)
ticker.Reset(duration)
}
stream.GetFrameRateCounter().Update()
samples <- sample
}
}
func (m *WebRTCManager) startVideoWriter() (chan media.Sample, <-chan struct{}) {
samples := make(chan media.Sample, 1)
done := make(chan struct{})
go func() {
defer close(done)
for sample := range samples {
packets := m.videoPacketizer.Packetize(sample.Data, uint32(sample.Duration.Seconds()*90000))
for _, client := range m.getClients() {
err := client.track.writeVideoPackets(packets)
if err != nil {
log.Errorf("failed to write h264 video to client: %s", err)
m.RemoveClient(client.WsConn())
client.Close()
}
}
}
}()
return samples, done
}

View File

@@ -157,11 +157,6 @@ func (s *SignalingHandler) handleVideoOffer(data string) error {
return err
}
if err := s.updateHeaderExtensionID(); err != nil {
log.Errorf("could not update header extension ID: %v", err)
return err
}
answerByte, err := json.Marshal(answer)
if err != nil {
log.Errorf("failed to marshal answer: %s", err)
@@ -171,30 +166,6 @@ func (s *SignalingHandler) handleVideoOffer(data string) error {
return s.client.WriteMessage("video-answer", string(answerByte))
}
// set extension ID
func (s *SignalingHandler) updateHeaderExtensionID() error {
receivers := s.client.video.GetReceivers()
if len(receivers) == 0 {
return errors.New("no RTP receiver found for video")
}
params := receivers[0].GetParameters()
if len(params.HeaderExtensions) == 0 {
return errors.New("no header extensions found in negotiated parameters")
}
for _, ext := range params.HeaderExtensions {
if ext.URI == "http://www.webrtc.org/experiments/rtp-hdrext/playout-delay" {
s.client.track.playoutDelayExtensionID = uint8(ext.ID)
log.Debugf("found and set playout delay extension ID to: %d", ext.ID)
return nil
}
}
log.Warnf("no track extension found in negotiated parameters, use default value 5")
return nil
}
// handle video candidate
func (s *SignalingHandler) handleVideoCandidate(data string) error {
candidate := webrtc.ICECandidateInit{}

View File

@@ -2,42 +2,11 @@ package webrtc
import (
"github.com/pion/rtp"
"github.com/pion/webrtc/v4/pkg/media"
log "github.com/sirupsen/logrus"
)
func (t *Track) updateExtension() {
if t.playoutDelayExtensionID == 0 {
t.playoutDelayExtensionID = 5
}
if len(t.playoutDelayExtensionData) == 0 {
playoutDelay := &rtp.PlayoutDelayExtension{
MinDelay: 0,
MaxDelay: 0,
}
playoutDelayExtensionData, err := playoutDelay.Marshal()
if err == nil {
t.playoutDelayExtensionData = playoutDelayExtensionData
}
}
}
func (t *Track) writeVideoSample(sample media.Sample) error {
samples := uint32(sample.Duration.Seconds() * 90000)
packets := t.videoPacketizer.Packetize(sample.Data, samples)
for _, p := range packets {
p.Header.Extension = true
p.Header.ExtensionProfile = 0xBEDE
if err := p.Header.SetExtension(t.playoutDelayExtensionID, t.playoutDelayExtensionData); err != nil {
log.Errorf("Failed to set extension: %v", err)
return err
}
if err := t.video.WriteRTP(p); err != nil {
log.Errorf("failed to write RTP: %v", err)
func (t *Track) writeVideoPackets(packets []*rtp.Packet) error {
for _, packet := range packets {
if err := t.video.WriteRTP(packet); err != nil {
return err
}
}

View File

@@ -10,11 +10,12 @@ import (
)
type WebRTCManager struct {
clients map[*websocket.Conn]*Client
clientSnapshot atomic.Pointer[[]*Client]
videoSending bool
mutex sync.Mutex
viewerVersion uint64
clients map[*websocket.Conn]*Client
clientSnapshot atomic.Pointer[[]*Client]
videoPacketizer rtp.Packetizer
videoSending bool
mutex sync.Mutex
viewerVersion uint64
}
type Client struct {
@@ -36,10 +37,7 @@ type SignalingHandler struct {
}
type Track struct {
playoutDelayExtensionID uint8
playoutDelayExtensionData []byte
videoPacketizer rtp.Packetizer
video *webrtc.TrackLocalStaticRTP
video *webrtc.TrackLocalStaticRTP
}
type Message struct {

View File

@@ -27,6 +27,7 @@ var (
setHDMI = func(enabled bool) { common.GetKvmVision().SetHDMI(enabled) }
isHdmiDisabled = utils.IsHdmiDisabled
getHdmiIdleTimeout = utils.GetHDMIIdleTimeout
getHdmiSignal = func() bool { return common.GetKvmVision().HasHDMISignal() }
)
func (s *Service) ResetHdmi(c *gin.Context) {
@@ -63,10 +64,12 @@ func (s *Service) DisableHdmi(c *gin.Context) {
func (s *Service) GetHdmiState(c *gin.Context) {
var rsp proto.Response
enabled := !isHdmiDisabled()
rsp.OkRspWithData(c, &proto.GetGetHdmiStateRsp{
Enabled: !utils.IsHdmiDisabled(),
IdleTimeout: utils.GetHDMIIdleTimeout(),
Enabled: enabled,
Signal: enabled && getHdmiSignal(),
IdleTimeout: getHdmiIdleTimeout(),
})
log.Debug("get hdmi state")

View File

@@ -0,0 +1,389 @@
package vm
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
)
var (
inputRegionFile = "/etc/kvm/input-region.json"
inputRegionMu sync.RWMutex
)
func (s *Service) GetInputResolution(c *gin.Context) {
var rsp proto.Response
width, widthErr := readPositiveInt("/kvmapp/kvm/width")
height, heightErr := readPositiveInt("/kvmapp/kvm/height")
if widthErr != nil || heightErr != nil {
rsp.ErrRsp(c, -1, "failed to read input resolution")
return
}
rsp.OkRspWithData(c, &proto.GetInputResolutionRsp{Width: width, Height: height})
}
func readPositiveInt(path string) (int, error) {
data, err := os.ReadFile(path)
if err != nil {
return 0, err
}
value, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil || value <= 0 {
return 0, errors.New("invalid positive integer")
}
return value, nil
}
func (s *Service) GetInputRegion(c *gin.Context) {
var rsp proto.Response
inputRegionMu.RLock()
region, err := readInputRegion(inputRegionFile)
inputRegionMu.RUnlock()
if errors.Is(err, os.ErrNotExist) {
rsp.OkRspWithData(c, &proto.GetInputRegionRsp{InputRegion: proto.InputRegion{Mode: "off"}})
return
}
if err != nil {
rsp.ErrRsp(c, -1, "failed to read input region")
return
}
if region == nil {
rsp.OkRspWithData(c, &proto.GetInputRegionRsp{InputRegion: proto.InputRegion{Mode: "off"}})
return
}
rsp.OkRspWithData(c, &proto.GetInputRegionRsp{InputRegion: *region})
}
func (s *Service) SetInputRegion(c *gin.Context) {
var req proto.SetInputRegionReq
var rsp proto.Response
if err := c.ShouldBindJSON(&req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
inputRegionMu.Lock()
defer inputRegionMu.Unlock()
if req.Mode == "off" || req.Mode == "auto" ||
(req.Mode == "manual" && req.FrameWidth == nil && req.FrameHeight == nil &&
req.Left == nil && req.Top == nil && req.Width == nil && req.Height == nil) {
region, err := readInputRegion(inputRegionFile)
if err != nil && !errors.Is(err, os.ErrNotExist) {
rsp.ErrRsp(c, -2, "failed to read input region")
return
}
if region == nil {
region = &proto.InputRegion{}
}
region.Mode = req.Mode
if req.Resolutions != nil {
if err := validateOriginalResolutions(*req.Resolutions); err != nil {
rsp.ErrRsp(c, -1, "invalid original resolutions")
return
}
region.Resolutions = *req.Resolutions
}
if req.SelectedResolution != nil {
region.SelectedResolution = *req.SelectedResolution
}
if req.Regions != nil {
if err := validateManualRegions(*req.Regions); err != nil {
rsp.ErrRsp(c, -1, "invalid manual regions")
return
}
region.Regions = *req.Regions
if len(region.Regions) == 0 {
clearLegacyManualRegion(region)
}
}
if req.SelectedRegion != nil {
region.SelectedRegion = *req.SelectedRegion
}
if err := validateSelections(*region); err != nil {
rsp.ErrRsp(c, -1, "invalid selected resolution")
return
}
if err := writeInputRegion(inputRegionFile, *region); err != nil {
rsp.ErrRsp(c, -2, "failed to save input region mode")
return
}
rsp.OkRsp(c)
return
}
if req.Mode != "manual" {
rsp.ErrRsp(c, -1, "invalid input region mode")
return
}
region, err := inputRegionFromRequest(req)
if err != nil {
rsp.ErrRsp(c, -1, "invalid input region")
return
}
previous, readErr := readInputRegion(inputRegionFile)
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
rsp.ErrRsp(c, -2, "failed to read input region")
return
}
if previous != nil {
region.Resolutions = previous.Resolutions
region.SelectedResolution = previous.SelectedResolution
region.Regions = previous.Regions
region.SelectedRegion = previous.SelectedRegion
}
if req.Resolutions != nil {
if err := validateOriginalResolutions(*req.Resolutions); err != nil {
rsp.ErrRsp(c, -1, "invalid original resolutions")
return
}
region.Resolutions = *req.Resolutions
}
if req.SelectedResolution != nil {
region.SelectedResolution = *req.SelectedResolution
}
if req.Regions != nil {
if err := validateManualRegions(*req.Regions); err != nil {
rsp.ErrRsp(c, -1, "invalid manual regions")
return
}
region.Regions = *req.Regions
if len(region.Regions) == 0 {
clearLegacyManualRegion(&region)
}
}
if req.SelectedRegion != nil {
region.SelectedRegion = *req.SelectedRegion
}
if err := validateSelections(region); err != nil {
rsp.ErrRsp(c, -1, "invalid selected resolution")
return
}
if err := writeInputRegion(inputRegionFile, region); err != nil {
rsp.ErrRsp(c, -2, "failed to save input region")
return
}
rsp.OkRsp(c)
}
func validateOriginalResolutions(resolutions []proto.OriginalResolution) error {
seen := make(map[proto.OriginalResolution]struct{}, len(resolutions))
for _, resolution := range resolutions {
if resolution.Width <= 0 || resolution.Height <= 0 {
return errors.New("resolution dimensions must be positive")
}
if _, ok := seen[resolution]; ok {
return errors.New("duplicate resolution")
}
seen[resolution] = struct{}{}
}
return nil
}
func validateSelectedResolution(selected string, resolutions []proto.OriginalResolution) error {
if selected == "" {
return nil
}
for _, resolution := range resolutions {
if selected == resolutionKey(resolution) {
return nil
}
}
return errors.New("selected resolution not found")
}
func validateManualRegions(regions []proto.ManualRegion) error {
seen := make(map[string]struct{}, len(regions))
for _, region := range regions {
if err := validateManualRegion(region); err != nil {
return err
}
key := manualRegionKey(region)
if _, ok := seen[key]; ok {
return errors.New("duplicate manual region")
}
seen[key] = struct{}{}
}
return nil
}
func validateManualRegion(region proto.ManualRegion) error {
return validateInputRegion(proto.InputRegion{
Mode: "manual",
FrameWidth: region.FrameWidth,
FrameHeight: region.FrameHeight,
Left: region.Left,
Top: region.Top,
Width: region.Width,
Height: region.Height,
})
}
func validateSelections(region proto.InputRegion) error {
if region.SelectedResolution != "" && region.SelectedRegion != "" {
return errors.New("only one input region source may be selected")
}
if err := validateSelectedResolution(region.SelectedResolution, region.Resolutions); err != nil {
return err
}
if region.SelectedRegion == "" {
return nil
}
for _, item := range region.Regions {
if region.SelectedRegion == manualRegionKey(item) {
return nil
}
}
return errors.New("selected manual region not found")
}
func resolutionKey(resolution proto.OriginalResolution) string {
return fmt.Sprintf("%dx%d", resolution.Width, resolution.Height)
}
func manualRegionKey(region proto.ManualRegion) string {
return fmt.Sprintf("%dx%d", region.Width, region.Height)
}
func clearLegacyManualRegion(region *proto.InputRegion) {
region.FrameWidth = 0
region.FrameHeight = 0
region.Left = 0
region.Top = 0
region.Width = 0
region.Height = 0
}
func inputRegionFromRequest(req proto.SetInputRegionReq) (proto.InputRegion, error) {
if req.FrameWidth == nil || req.FrameHeight == nil || req.Left == nil ||
req.Top == nil || req.Width == nil || req.Height == nil {
return proto.InputRegion{}, errors.New("input region must be complete")
}
region := proto.InputRegion{
Mode: "manual",
FrameWidth: *req.FrameWidth,
FrameHeight: *req.FrameHeight,
Left: *req.Left,
Top: *req.Top,
Width: *req.Width,
Height: *req.Height,
}
return region, validateInputRegion(region)
}
func validateInputRegion(region proto.InputRegion) error {
if region.Mode == "off" || region.Mode == "auto" {
return nil
}
if region.Mode == "manual" && region.FrameWidth == 0 && region.FrameHeight == 0 {
return nil
}
if region.Mode != "manual" {
return errors.New("invalid input region mode")
}
if region.FrameWidth <= 0 || region.FrameHeight <= 0 {
return errors.New("frame dimensions must be positive")
}
if region.Left < 0 || region.Top < 0 || region.Width <= 0 || region.Height <= 0 {
return errors.New("region dimensions must be positive and offsets non-negative")
}
if region.Left > region.FrameWidth-region.Width || region.Top > region.FrameHeight-region.Height {
return errors.New("region must be inside frame")
}
return nil
}
func readInputRegion(path string) (*proto.InputRegion, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var region *proto.InputRegion
if err := json.Unmarshal(data, &region); err != nil {
return nil, nil
}
if region == nil {
return nil, nil
}
if region.Mode == "" {
region.Mode = "manual"
}
if len(region.Regions) == 0 && region.FrameWidth > 0 && region.FrameHeight > 0 {
manual := proto.ManualRegion{
FrameWidth: region.FrameWidth,
FrameHeight: region.FrameHeight,
Left: region.Left,
Top: region.Top,
Width: region.Width,
Height: region.Height,
}
region.Regions = []proto.ManualRegion{manual}
if region.SelectedResolution == "" {
region.SelectedRegion = manualRegionKey(manual)
}
}
if err := validateInputRegion(*region); err != nil {
return nil, nil
}
if err := validateManualRegions(region.Regions); err != nil {
return nil, nil
}
if err := validateSelections(*region); err != nil {
return nil, nil
}
return region, nil
}
func writeInputRegion(path string, region proto.InputRegion) error {
data, err := json.Marshal(region)
if err != nil {
return err
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".input-region-*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(0o644); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpPath, path)
}
func removeInputRegion(path string) error {
err := os.Remove(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}

View File

@@ -1,8 +1,8 @@
package vm
import (
"NanoKVM-Server/middleware"
"encoding/json"
"net/http"
"os"
"os/exec"
"time"
@@ -26,9 +26,7 @@ type WinSize struct {
var upgrader = websocket.Upgrader{
ReadBufferSize: maxMessageSize,
WriteBufferSize: maxMessageSize,
CheckOrigin: func(r *http.Request) bool {
return true
},
CheckOrigin: middleware.CheckWebSocketOrigin,
}
func (s *Service) Terminal(c *gin.Context) {
@@ -37,11 +35,14 @@ func (s *Service) Terminal(c *gin.Context) {
log.Errorf("failed to init websocket: %s", err)
return
}
stopSessionWatcher := middleware.WatchWebSocket(c.Request.Context(), ws)
defer stopSessionWatcher()
defer func() {
_ = ws.Close()
}()
cmd := exec.Command("/bin/sh")
cmd := exec.Command("/bin/sh", "-l")
cmd.Dir = "/root"
ptmx, err := pty.Start(cmd)
if err != nil {
log.Errorf("failed to start pty: %s", err)

View File

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

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)

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