Files
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
..
2025-03-20 18:31:09 +08:00
2025-11-26 10:57:30 +08:00
2026-09-01 15:34:20 +08:00

NanoKVM Server

This is the backend server implementation for NanoKVM.

For detailed documentation, please visit our Wiki.

Structure

server
├── common       // Common utility components
├── config       // Server configuration
├── dl_lib       // Shared object libraries
├── include      // Header files for shared objects
├── logger       // Logging system
├── middleware   // Server middleware components
├── proto        // API request/response definitions
├── router       // API route handlers
├── service      // Core service implementations
├── utils        // Utility functions
└── main.go

Configuration

The configuration file path is /etc/kvm/server.yaml.

# Network Settings
proto: http            # Access protocol. Can be changed to `https` only when certificates are configured. Default is `http`
host: ""               # The listening address for the HTTP/HTTPS service. If left empty, all network interfaces will be bound
port:
    http: 80           # The listening port for the HTTP service. Default is `80`
    https: 443         # The listening port for the HTTPS service (effective when HTTPS is enabled). Default is `443`
cert:
    crt: server.crt    # The path to the public key certificate for HTTPS
    key: server.key    # The path to the private key file for HTTPS


# Logging Configuration
logger:
    level: info     # Global log output level. Evaluated options from highest to lowest detail: `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`. Default is `info`
    file: stdout    # Log output destination. `stdout` outputs to the standard console. A file path directs log output to that file. Default is `stdout`


# Authentication & Security
authentication: enable              # Whether to enable identity verification for HTTP API and Web endpoints. Options are `enable` or `disable`. Default is `enable`. Highly recommended to leave this enabled for internet-facing devices!
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 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`


# WebRTC Traversal Settings
stun: stun.l.google.com:19302 # The default STUN server address used for NAT hole-punching to establish P2P streams
turn:
    turnAddr: example_addr    # The relay (TURN) server address (format `ip:port`) used as a fallback when P2P connection fails. Leave empty to disable TURN relay
    turnUser: example_user    # The username required for authorization to the TURN server
    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: 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 (make shell) or the dev container (see "Development" in the root README) — 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.
    2. Extract the file and add the host-tools/gcc/riscv64-linux-musl-x86_64/bin directory to your PATH environment variable.
    3. Run riscv64-unknown-linux-musl-gcc -v. If there is version information in the output, the installation is successful.
  2. Compile the Project

    1. Run cd server from the project root directory.
    2. Run go mod tidy to install Go dependencies.
    3. (Optional) If you compiled libkvm.so yourself, you need to modify its RPATH by patchelf --add-rpath \$ORIGIN ./dl_lib/libkvm.so.
    4. Run CGO_ENABLED=1 GOOS=linux GOARCH=riscv64 CC=riscv64-unknown-linux-musl-gcc CGO_CFLAGS="-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d" go build to compile the project.
    5. After compilation, an executable file named NanoKVM-Server will be generated.
  3. Modify RPATH

    1. Run sudo apt install patchelf or pip install patchelf to install patchelf.
    2. Run patchelf --version. Ensure the version is 0.14 or higher`.
    3. Run patchelf --add-rpath \$ORIGIN/dl_lib NanoKVM-Server to modify the RPATH of the executable file.
  4. Deploy the Application

    1. File uploads requires SSH. Please enable it in the Web Settings: Settings > SSH;
    2. Replace the original file in the NanoKVM /kvmapp/server/ directory with the newly compiled NanoKVM-Server.
    3. Restart the service on NanoKVM by executing /etc/init.d/S95nanokvm restart.

Manually Update

File uploads requires SSH. Please enable it in the Web Settings: Settings > SSH;

  1. Download the latest application from GitHub;
  2. Unzip the downloaded file and rename the unzipped folder to kvmapp;
  3. Back up the existing /kvmapp directory on your NanoKVM, then replace it with the new kvmapp folder;
  4. Run /etc/init.d/S95nanokvm restart on your NanoKVM to restart the service.